[cig-commits] r8967 - cs/portal/trunk/magportal
wei at geodynamics.org
wei at geodynamics.org
Fri Dec 21 10:12:27 PST 2007
Author: wei
Date: 2007-12-21 10:12:27 -0800 (Fri, 21 Dec 2007)
New Revision: 8967
Added:
cs/portal/trunk/magportal/daemon.py
Log:
Mag portal daemon.
Added: cs/portal/trunk/magportal/daemon.py
===================================================================
--- cs/portal/trunk/magportal/daemon.py 2007-12-21 02:09:42 UTC (rev 8966)
+++ cs/portal/trunk/magportal/daemon.py 2007-12-21 18:12:27 UTC (rev 8967)
@@ -0,0 +1,723 @@
+
+import stackless
+import os, sys, signal
+from popen2 import Popen4
+
+from pyre.applications import Script
+from pyre.components import Component
+from pyre.units.time import second
+
+
+# NYI: RSL AST
+class RSLUnquoted(object):
+ def __init__(self, value):
+ self.value = value
+
+TG_CLUSTER_SCRATCH = "/work/teragrid/tg459131"
+TG_COMMUNITY = "/projects/tg"
+MAG_DIR = TG_COMMUNITY + "/CIG/MAG/"
+
+# I'm not sure what to do about this yet.
+TACC_ENVIRONMENT = """(environment=(TG_CLUSTER_SCRATCH "%s") (TG_COMMUNITY "%s") (PATH "/opt/lsf/bin:/opt/lsf/etc:/opt/MPI/intel9/mvapich-gen2/0.9.8/bin:/opt/apps/binutils/binutils-2.17/bin:/opt/intel/compiler9.1//idb/bin:/opt/intel/compiler9.1//cc/bin:/opt/intel/compiler9.1//fc/bin:/usr/local/first:/usr/local/bin:~/bin:.:/opt/apps/pki_apps:/opt/apps/gsi-openssh-3.9/bin:/opt/lsf/bin:/opt/lsf/etc:/sbin:/usr/sbin:/usr/local/sbin:/bin:/usr/bin:/usr/local/bin:/usr/X11R6/bin:/home/teragrid/tg459131/bin:/data/TG/srb-client-3.4.1-r1/bin:/data/TG/softenv-1.6.2-r3/bin:/data/TG/tg-policy/bin:/data/TG/gx-map-0.5.3.2-r1/bin:/data/TG/tgusage-2.9-r2/bin:/usr/java/j2sdk1.4.2_12/bin:/usr/java/j2sdk1.4.2_12/jre/bin:/data/TG/globus-4.0.1-r3/sbin:/data/TG/globus-4.0.1-r3/bin:/data/TG/tgcp-1.0.0-r2/bin:/data/TG/condor-6.7.18-r1/bin:/data/TG/condor-6.7.18-r1/sbin:/data/TG/hdf4-4.2r1-r1/bin:/opt/apps/hdf5/hdf5-1.6.5/bin:/data/TG/phdf5-1.6.5/bin") (MPICH_HOME "/opt/MPI/intel9/mvapich-gen2/0.9.8"))""" % (TG_CLUSTER_SCRATCH, TG_COMMUNITY)
+
+
+
+class GassServer(object):
+
+ # @classmethod
+ def startUp(cls, directory):
+ argv = ["globus-gass-server", "-r", "-w", "-c"]
+ savedWd = os.getcwd()
+ os.chdir(directory)
+ child = Popen4(argv)
+ os.chdir(savedWd)
+ child.tochild.close()
+ url = child.fromchild.readline().strip()
+ return GassServer(child, url, directory)
+ startUp = classmethod(startUp)
+
+ def __init__(self, child, url, directory):
+ self.child = child
+ self.url = url
+ self.directory = directory
+
+ def shutDown(self):
+ argv = ["globus-gass-server-shutdown", self.url]
+ os.spawnvp(os.P_NOWAIT, argv[0], argv)
+ status = self.child.wait()
+ return
+
+
+class JobManager(object):
+
+ def __init__(self, clock, root, info):
+ self.clock = clock
+ self.root = root
+ self.info = info
+
+
+ def runJob(self, job):
+ self.createSubdirectoryForJob(job)
+ stackless.tasklet(self.jobRunner)(job)
+
+
+ def spawn(self, *argv):
+ command = ' '.join(argv)
+ self.info.log("spawning: %s" % command)
+ status = os.spawnvp(os.P_WAIT, argv[0], argv)
+ statusMsg = "%s: exit %d" % (argv[0], status)
+ self.info.log(statusMsg)
+ if status != 0:
+ raise Exception(statusMsg)
+ return
+
+
+ def ospawn(self, *argv):
+ command = ' '.join(argv)
+ self.info.log("spawning: %s" % command)
+
+ child = Popen4(argv)
+
+ child.tochild.close()
+
+ output = child.fromchild.readlines()
+ status = child.wait()
+
+ exitStatus = None
+ if (os.WIFSIGNALED(status)):
+ statusStr = "signal %d" % os.WTERMSIG(status)
+ elif (os.WIFEXITED(status)):
+ exitStatus = os.WEXITSTATUS(status)
+ statusStr = "exit %d" % exitStatus
+ else:
+ statusStr = "status %d" % status
+ statusMsg = "%s: %s" % (argv[0], statusStr)
+
+ for line in output:
+ self.info.line(" " + line.rstrip())
+ self.info.log(statusMsg)
+
+ if exitStatus != 0:
+ raise Exception(statusMsg)
+
+ return output
+
+
+ def download(self, url, pathname):
+ import shutil
+ import urllib2
+ self.info.log("downloading %s" % url)
+ infile = urllib2.urlopen(url)
+ outfile = open(pathname, 'wb')
+ shutil.copyfileobj(infile, outfile)
+ outfile.close()
+ infile.close()
+ return
+
+ def downloadInputFilesForJob(self, job):
+ for inputFile in job.inputFiles:
+ url = job.urlForInputFile(inputFile)
+ filename = inputFile.split('/')[-1] # strips 'mineos/'
+ pathname = os.path.join(job.directory, filename)
+ self.download(url, pathname)
+ return
+
+ def createSubdirectoryForJob(self, job):
+ while True:
+ dirName = randomName()
+ dirPath = os.path.join(self.root, dirName)
+ if not os.path.exists(dirPath):
+ os.mkdir(dirPath)
+ break
+ job.subdir = dirName
+ job.directory = dirPath
+ return
+
+
+class ForkJobManager(JobManager):
+
+ def jobRunner(self, job):
+
+ self.downloadInputFilesForJob(job)
+
+ try:
+ argv = [job.resSpec['executable']] + job.resSpec['arguments']
+ command = ' '.join(argv)
+ self.info.log("spawning: %s" % command)
+ savedWd = os.getcwd()
+ os.chdir(job.directory)
+ pid = os.spawnvp(os.P_NOWAIT, argv[0], argv)
+ os.chdir(savedWd)
+ self.info.log("spawned process %d" % pid)
+
+ ticks = 2
+ wpid, status = os.waitpid(pid, os.WNOHANG)
+ while wpid == 0:
+ for t in xrange(ticks):
+ self.clock.tick.wait()
+ ticks *= 2
+ wpid, status = os.waitpid(pid, os.WNOHANG)
+
+ if (os.WIFSIGNALED(status)):
+ statusStr = "signal %d" % os.WTERMSIG(status)
+ job.setStatus(job.STATUS_FAILED)
+ elif (os.WIFEXITED(status)):
+ exitStatus = os.WEXITSTATUS(status)
+ statusStr = "exit %d" % exitStatus
+ if exitStatus == 0:
+ job.setStatus(job.STATUS_DONE)
+ else:
+ job.setStatus(job.STATUS_FAILED)
+ else:
+ statusStr = "status %d" % status
+ job.setStatus(job.STATUS_FAILED)
+ statusMsg = "%s: %s" % (argv[0], statusStr)
+ self.info.log(statusMsg)
+
+ except Exception, e:
+ self.info.log("error: %s: %s" % (e.__class__.__name__, e))
+ job.setStatus(job.STATUS_FAILED)
+
+ return
+
+
+
+class GlobusJobManager(JobManager):
+
+ def jobRunner(self, job):
+
+ self.downloadInputFilesForJob(job)
+ gassServer = GassServer.startUp(job.directory)
+
+ try:
+ resSpec = self.resSpec(job, gassServer)
+ id = self.globusrun(resSpec)
+
+ oldStatus = job.status
+ ticks = 2
+ while job.isAlive():
+ for t in xrange(ticks):
+ self.clock.tick.wait()
+ ticks *= 2
+ status = self.getJobStatus(id)
+ if status != oldStatus:
+ job.setStatus(status)
+ oldStatus = status
+ ticks = 2
+
+ except Exception, e:
+ self.info.log("error: %s: %s" % (e.__class__.__name__, e))
+ job.setStatus(job.STATUS_FAILED)
+
+ finally:
+ gassServer.shutDown()
+
+ return
+
+
+ def globusrun(self, resSpec):
+ import tempfile
+ fd, rslName = tempfile.mkstemp(suffix=".rsl")
+ stream = os.fdopen(fd, "w")
+ try:
+ self.writeRsl(resSpec, stream)
+ stream.close()
+ if resSpec['jobType'] == "mpi":
+ resourceManager = "tg-login.tacc.teragrid.org/jobmanager-lsf"
+ else:
+ resourceManager = "tg-login.tacc.teragrid.org/jobmanager-fork"
+ output = self.ospawn("globusrun", "-F", "-f", rslName, "-r", resourceManager)
+ id = None
+ for line in output:
+ if line.startswith("https://"):
+ id = line.strip()
+ assert id
+ finally:
+ stream.close()
+ os.unlink(rslName)
+ return id
+
+
+ def writeRsl(self, resSpec, stream):
+ print >>stream, '&'
+ for relation in resSpec.iteritems():
+ attribute, valueSequence = relation
+ valueSequence = self.rslValueSequenceToString(valueSequence)
+ print >>stream, '(', attribute, '=', valueSequence, ')'
+ print >>stream, TACC_ENVIRONMENT
+ return
+
+
+ def rslValueSequenceToString(self, valueSequence):
+ if not isinstance(valueSequence, (tuple, list)):
+ valueSequence = [valueSequence]
+ s = []
+ for value in valueSequence:
+ if isinstance(value, RSLUnquoted):
+ s.append(value.value)
+ elif isinstance(value, (tuple, list)):
+ s.append('(' + self.rslValueSequenceToString(value) + ')')
+ else:
+ s.append('"%s"' % value)
+ return ' '.join(s)
+
+
+ def getJobStatus(self, id):
+ output = self.ospawn("globus-job-status", id)
+ status = output[0].strip()
+ return status
+
+
+ def resSpec(self, job, gassServer):
+ resSpec = {}
+ resSpec.update(job.resSpec)
+ resSpec["scratch_dir"] = TG_CLUSTER_SCRATCH
+ resSpec["directory"] = RSLUnquoted("$(SCRATCH_DIRECTORY)")
+
+ file_stage_in = []
+ for inputFile in job.inputFiles:
+ url = gassServer.url + "/./" + inputFile
+ file_stage_in.append([url, RSLUnquoted("$(SCRATCH_DIRECTORY) # " + '"/' + inputFile + '"')])
+ resSpec["file_stage_in"] = file_stage_in
+
+ file_stage_out = []
+ for outputFile in job.outputFiles:
+ url = gassServer.url + "/./" + outputFile
+ file_stage_out.append([outputFile, url])
+ if file_stage_out:
+ resSpec["file_stage_out"] = file_stage_out
+
+ resSpec["stdout"] = gassServer.url + "/./stdout.txt"
+ resSpec["stderr"] = gassServer.url + "/./stderr.txt"
+
+ job.outputFiles.extend(["stdout.txt", "stderr.txt"])
+
+ return resSpec
+
+
+class Event(object):
+
+ def __init__(self):
+ self.channel = stackless.channel()
+
+ def wait(self):
+ self.channel.receive()
+ return
+
+ def signal(self):
+
+ # Swap-in a new channel, so that a waiting tasklet that
+ # repeatedly calls wait() will block waiting for the *next*
+ # signal on its subsequent call to wait().
+ channel = self.channel
+ self.channel = stackless.channel()
+
+ # Unblock all waiters.
+ while channel.balance < 0:
+ channel.send(None)
+
+ return
+
+
+class Job(object):
+
+ # status codes
+ STATUS_NEW = "NEW" # pseudo
+ STATUS_PENDING = "PENDING"
+ STATUS_ACTIVE = "ACTIVE"
+ STATUS_DONE = "DONE"
+ STATUS_FAILED = "FAILED"
+ statusCodes = [STATUS_PENDING, STATUS_ACTIVE, STATUS_DONE, STATUS_FAILED]
+ deadCodes = [STATUS_DONE, STATUS_FAILED]
+
+ def __init__(self, task, **resSpec):
+ self.task = task
+ self.resSpec = resSpec
+ self.status = self.STATUS_NEW
+ self.statusChanged = Event()
+ self.inputFiles = []
+ self.urlForInputFile = None
+ self.outputFiles = []
+ self.subdir = None
+ self.directory = None
+
+ def setStatus(self, status):
+ if False: # Violated by, e.g., "UNKNOWN JOB STATE 64"
+ assert status in self.statusCodes, "unknown status: %s" % status
+ self.status = status
+ self.statusChanged.signal()
+
+ def isAlive(self):
+ return not self.status in self.deadCodes
+
+
+class Run(object):
+
+ # status codes
+ STATUS_NEW = "new"
+ STATUS_CONNECTING = "connecting"
+ STATUS_PREPARING = "preparing" # reported by build & schedule process
+ STATUS_PENDING = "pending" # reported by build & schedule process
+ STATUS_FINISHING = "finishing" # reported by launcher process
+ STATUS_DONE = "done"
+ STATUS_ERROR = "error"
+ deadCodes = [STATUS_DONE, STATUS_ERROR]
+
+ def __init__(self, id, simulation, urlForInputFile, config, info):
+ self.id = id
+ self.simulation = simulation
+ self.urlForInputFile = urlForInputFile
+ self.dry = config.dry
+ self.info = info
+
+ self.jobChannel = stackless.channel()
+ self.status = self.STATUS_NEW
+ self.statusChanged = Event()
+
+ def go(self, gjm):
+ stackless.tasklet(self)(gjm)
+
+ def __call__(self, gjm):
+ try:
+ self.setStatus(self.STATUS_CONNECTING)
+
+ # run
+ mag = self.newMagJob()
+ gjm.runJob(mag)
+
+ # send jobs to jobSink()
+ self.jobChannel.send(mag)
+ self.jobChannel.send(None) # no more jobs
+
+ self.setStatus(self.STATUS_PREPARING)
+
+ while mag.isAlive():
+ mag.statusChanged.wait()
+ # while
+
+ if mag.status == mag.STATUS_FAILED:
+ self.setStatus(self.STATUS_ERROR)
+ raise RuntimeError("run failed")
+
+ self.setStatus(self.STATUS_DONE)
+
+ except Exception, e:
+ self.info.log("error: %s: %s" % (e.__class__.__name__, e))
+ self.setStatus(self.STATUS_ERROR)
+
+ return
+
+ def setStatus(self, status):
+ self.status = status
+ self.statusChanged.signal()
+
+ def isAlive(self):
+ return not self.status in self.deadCodes
+
+ def newMagJob(self):
+
+ import urllib2
+ argsUrl = self.urlForInputFile("args.py")
+ self.info.log("GET %s" % argsUrl)
+ infile = urllib2.urlopen(argsUrl)
+ self.info.log("OK")
+ args = infile.read()
+ infile.close()
+ args = eval(args)
+
+ job = Job(
+ "run",
+ jobType = "single",
+ count = 1,
+ executable = MAG_DIR + "release/runMag-arg.sh",
+ arguments = args + [self.simulation.parfile]
+ )
+ job.urlForInputFile = self.urlForInputFile
+ sim = self.simulation
+ job.inputFiles = [sim.parfile]
+ job.outputFiles = ["mag.tar.gz"]
+ return job
+
+class Simulation(object):
+ def __init__(self, id):
+ self.id = id
+ self.parfile = 'ParFile.txt'
+
+class PortalConnection(object):
+
+ MULTIPART_BOUNDARY = '----------eArThQuAkE$'
+
+ def __init__(self, portal, outputRootUrl, clock, info):
+ self.portal = portal
+ self.outputRootUrl = outputRootUrl
+ self.clock = clock
+ self.info = info
+
+ def runSimulations(self, gjm, config):
+ stackless.tasklet(self.runFactory)(gjm, config)
+
+ def runFactory(self, gjm, config):
+ import urllib2
+
+ runs = {}
+
+ while True:
+ self.clock.tick.wait()
+
+ self.info.log("GET %s" % self.portal.runsUrl)
+ try:
+ infile = urllib2.urlopen(self.portal.runsUrl)
+ except Exception, e:
+ # Could be transient failure -- e.g., "connection reset by peer".
+ self.info.log("error: %s" % e)
+ continue
+ #self.info.log("OK")
+ runList = infile.read()
+ infile.close()
+
+ runList = eval(runList)
+
+ for run in runList:
+ id = int(run['id'])
+ status = run['status']
+ simId = run['simulation']
+ if (status in [Run.STATUS_NEW, ""] and
+ not runs.has_key(id)):
+ self.info.log("new run %d" % id)
+ simulation = Simulation(simId)
+
+ def urlForInputFile(inputFile):
+ # Map input filenames to URLs in the context
+ # of this run.
+ return self.inputFileURL(simulation, inputFile)
+
+ newRun = Run(id, simulation, urlForInputFile, config, self.info)
+
+ self.watchRun(newRun)
+ runs[id] = newRun
+ newRun.go(gjm)
+
+ return
+
+ def watchRun(self, run):
+ stackless.tasklet(self.runWatcher)(run)
+ stackless.tasklet(self.jobSink)(run)
+
+ def runWatcher(self, run):
+ url = self.portal.runStatusUrl % run.id
+ while run.isAlive():
+ run.statusChanged.wait()
+ fields = {'status': run.status}
+ self.postStatusChange(url, fields)
+ return
+
+ def jobSink(self, run):
+ newJob = run.jobChannel.receive()
+ while newJob is not None:
+ self.watchJob(newJob, run)
+ newJob = run.jobChannel.receive()
+ return
+
+ def watchJob(self, job, run):
+ url = self.portal.jobCreateUrl
+ fields = self.jobFields(job, run)
+ response = self.postStatusChange(url, fields)
+ portalId = eval(response)
+ stackless.tasklet(self.jobWatcher)(job, portalId, run)
+
+ def jobWatcher(self, job, portalId, run):
+ url = self.portal.jobUpdateUrl % portalId
+ while job.isAlive():
+ job.statusChanged.wait()
+ fields = self.jobFields(job, run)
+ self.postStatusChange(url, fields)
+ self.postJobOutput(job, portalId)
+ return
+
+ def postJobOutput(self, job, portalId):
+ url = self.portal.outputCreateUrl
+
+ for outputFile in job.outputFiles:
+
+ pathname = os.path.join(job.directory, outputFile)
+ if not os.path.exists(pathname):
+ continue
+
+ # in case the daemon and the web server are run as
+ # different users
+ import stat
+ os.chmod(pathname, stat.S_IRUSR | stat.S_IRGRP | stat.S_IROTH)
+
+ fields = {
+ 'job': portalId,
+ 'name': outputFile,
+ }
+ self.postStatusChange(url, fields)
+
+ return
+
+ def jobFields(self, job, run):
+ fields = {
+ 'run': run.id,
+ 'task': job.task,
+ 'status': job.status.lower(),
+ 'url': self.outputRootUrl + job.subdir + "/",
+ }
+ return fields
+
+ def inputFileURL(self, simulation, inputFile):
+ return self.portal.inputFileUrl % (simulation.id, inputFile)
+
+ def postStatusChange(self, url, fields):
+ import urllib
+ body = urllib.urlencode(fields)
+ headers = {"Content-Type": "application/x-www-form-urlencoded",
+ "Accept": "text/plain"}
+ return self.post(body, headers, url)
+
+ def postStatusChangeAndUploadOutput(self, url, fields, output):
+ # Based upon a recipe by Wade Leftwich.
+ files = [('output', 'output.txt', 'application/x-gtar', 'gzip', output)]
+ body = self.encodeMultipartFormData(fields, files)
+ headers = {"Content-Type": "multipart/form-data; boundary=%s" % self.MULTIPART_BOUNDARY,
+ "Content-Length": str(len(body)),
+ "Accept": "text/plain"}
+ return self.post(body, headers, url)
+
+ def encodeMultipartFormData(self, fields, files):
+ import shutil
+ from StringIO import StringIO
+ stream = StringIO()
+ def line(s=''): stream.write(s + '\r\n')
+ for key, value in fields.iteritems():
+ line('--' + self.MULTIPART_BOUNDARY)
+ line('Content-Disposition: form-data; name="%s"' % key)
+ line()
+ line(value)
+ for (key, filename, contentType, contentEncoding, content) in files:
+ line('--' + self.MULTIPART_BOUNDARY)
+ line('Content-Disposition: form-data; name="%s"; filename="%s"' % (key, filename))
+ line('Content-Type: %s' % contentType)
+ line('Content-Encoding: %s' % contentEncoding)
+ line()
+ shutil.copyfileobj(content, stream)
+ line()
+ line('--' + self.MULTIPART_BOUNDARY + '--')
+ line()
+ return stream.getvalue()
+
+ def post(self, body, headers, url):
+ self.info.log("POST %s" % url)
+ import httplib
+ if self.portal.scheme == "http":
+ conn = httplib.HTTPConnection(self.portal.host)
+ elif self.portal.scheme == "https":
+ conn = httplib.HTTPSConnection(self.portal.host)
+ else:
+ assert False # not scheme in ["http", "https"]
+ conn.request("POST", url, body, headers)
+ response = conn.getresponse()
+ data = response.read()
+ conn.close()
+ if len(data) < 10:
+ self.info.log("response %s" % data)
+ else:
+ global counter
+ counter = counter + 1
+ fn = "response-%d.html" % counter
+ f = open(fn, "w")
+ f.write(data)
+ f.close()
+ self.info.log("response is in %s" % fn)
+ return data
+
+
+counter = 0
+
+
+class Clock(object):
+
+ def __init__(self, interval):
+ self.interval = interval
+ self.tick = Event()
+ stackless.tasklet(self)()
+
+ def __call__(self):
+ from time import sleep
+ while True:
+ sleep(self.interval)
+ self.tick.signal()
+ stackless.schedule()
+ return
+
+
+class WebPortal(Component):
+
+ name = "web-portal"
+
+ import pyre.inventory as pyre
+ scheme = pyre.str("scheme", validator=pyre.choice(["http", "https"]), default="http")
+ host = pyre.str("host", default="localhost:8000")
+ urlRoot = pyre.str("url-root", default="/magwebportal/")
+
+ def _configure(self):
+ self.urlPrefix = '%s://%s%s' % (self.scheme, self.host, self.urlRoot)
+ self.inputFileUrl = self.urlPrefix + 'simulations/%d/%s'
+ # runs
+ self.runsUrl = self.urlPrefix + 'runs/list.py'
+ self.runStatusUrl = self.urlRoot + 'runs/%d/status/'
+ # jobs
+ self.jobCreateUrl = self.urlRoot + 'jobs/create/'
+ self.jobUpdateUrl = self.urlRoot + 'jobs/%d/update/'
+ # output
+ self.outputCreateUrl = self.urlRoot + 'output/create/'
+
+
+class Daemon(Script):
+
+ name = "web-portal-daemon"
+
+ import pyre.inventory as pyre
+ portal = pyre.facility("portal", factory=WebPortal)
+ sleepInterval = pyre.dimensional("sleep-interval", default=60*second)
+
+ outputRootPathname = pyre.str("output-root-pathname")
+ outputRootUrl = pyre.str("output-root-url")
+
+ dry = pyre.bool("dry", default=False)
+
+
+ def main(self, *args, **kwds):
+ self._info.activate()
+ self._info.log("~~~~~~~~~~ daemon started ~~~~~~~~~~")
+ clock = Clock(self.sleepInterval / second)
+ gjm = GlobusJobManager(clock, self.outputRootPathname, self._info)
+ connection = PortalConnection(self.portal, self.outputRootUrl, clock, self._info)
+ connection.runSimulations(gjm, self)
+ try:
+ stackless.run()
+ except KeyboardInterrupt:
+ self._info.log("~~~~~~~~~~ daemon stopped ~~~~~~~~~~")
+ return
+
+
+def randomName():
+ # Stolen from pyre.services.Pickler; perhaps this should be an
+ # official/"exported" Pyre function?
+
+ alphabet = list("0123456789abcdefghijklmnopqrstuvwxyz")
+
+ import random
+ random.shuffle(alphabet)
+ key = "".join(alphabet)[0:16]
+
+ return key
+
+
+def main(*args, **kwds):
+ daemon = Daemon()
+ daemon.run(*args, **kwds)
+
+
+if __name__ == "__main__":
+ main()
More information about the cig-commits
mailing list