gstlal 1.13.0
Loading...
Searching...
No Matches
dagfile.py
1# Copyright (C) 2011--2015 Kipp Cannon
2# Copyright (C) 2004--2006 Brian Moe
3#
4# This program is free software; you can redistribute it and/or modify it
5# under the terms of the GNU General Public License as published by the
6# Free Software Foundation; either version 3 of the License, or (at your
7# option) any later version.
8#
9# This program is distributed in the hope that it will be useful, but
10# WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
12# Public License for more details.
13#
14# You should have received a copy of the GNU General Public License along
15# with this program; if not, write to the Free Software Foundation, Inc.,
16# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
18"""
19Machinery for reading, editing, and writing Condor DAG files.
20
21When running DAGs on Condor compute clusters, very often one will wish to
22re-run a portion of a DAG. This can be done by marking all jobs except the
23ones to be re-run as "DONE". Unfortunately the Condor software suite lacks
24an I/O library for reading and writing Condor DAG files, so there is no
25easy way to edit DAG files except by playing games sed, awk, or once-off
26Python or Perl scripts. That's where this module comes in. This module
27will read a DAG file into an in-ram representation that is easily edited,
28and allow the file to be written to disk again.
29
30Example:
31
32>>> from gstlal import dagfile
33>>> dag = dagfile.DAG.parse(open("pipeline.dag"))
34>>> dag.write(open("pipeline.dag", "w"))
35
36Although it is possible to machine-generate an original DAG file using this
37module and write it to disk, this module does not provide the tools
38required to do any of the other tasks associated with pipeline
39construction. For example there is no facility here to generate or manage
40submit files, data files, or any other files that are associated with a
41full pipeline. Only the DAG file itself is considered here. For general
42pipeline construction see the pipeline module. The focus of this module is
43on editing existing DAG files.
44
45Developers should also consider doing any new pipeline development using
46DAX files as the fundamental workflow description, instead of DAGs. See
47http://pegasus.isi.edu for more information.
48
49A DAG file is loaded using the .parse() class method of the DAG class.
50This parses the file-like object passed to it and returns an instance of
51the DAG class representing the file's contents. Once loaded, the nodes in
52the DAG can all be found in the .nodes dictionary, whose keys are the node
53names and whose values are the corresponding node objects. Among each node
54object's attributes are sets .children and .parents containing references
55to the child and parent nodes (not their names) for each node. Note that
56every node must appear listed as a parent of each of its children, and vice
57versa. The other attributes of a DAG instance contain information about
58the DAG, for example the CONFIG file or the DOT file, and so on. All of
59the data for each node in the DAG, for example the node's VARS value, its
60initial working directory, and so on, can be found in the attributes of the
61nodes themselves. A DAG is written to a file using the .write() method of
62the DAG object.
63"""
64
65
66#
67# =============================================================================
68#
69# Preamble
70#
71# =============================================================================
72#
73
74
75import copy
76import itertools
77import re
78
79
80__all__ = ["DAG", "JOB", "DATA", "SPLICE", "SUBDAG_EXTERNAL"]
81
82
83#
84# =============================================================================
85#
86# Progress Wrapper
87#
88# =============================================================================
89#
90
91
92class progress_wrapper(object):
93 """
94 Progress report wrapper. For internal use only.
95 """
96 def __init__(self, f, callback):
97 self.n = 0
98 self.f = f
99 self.callback = callback
100
101 def __iadd__(self, dn):
102 self.n += dn
103 if self.callback is not None and not self.n % 7411:
104 self.callback(self.f, self.n, False)
105 return self
106
107 def __del__(self):
108 if self.callback is not None:
109 self.callback(self.f, self.n, True)
110
111
112class nofile(object):
113 """
114 Object providing a no-op .write() method to fake a file. For
115 internal use only.
116 """
117 def write(self, *args):
118 pass
119
120
121#
122# =============================================================================
123#
124# The Contents of a Condor DAG File
125#
126# =============================================================================
127#
128
129
130class JOB(object):
131 """
132 Representation of a JOB node in a Condor DAG. JOB objects have the
133 following attributes corresponding to information in the DAG file:
134
135 .name
136 The name of the node in the DAG.
137
138 .filename
139 The name of the submit file for the JOB.
140
141 .directory
142 The initial working directory for the JOB. Set to None to
143 omit from DAG (job's working directory will be chosen by
144 Condor).
145
146 .done
147 Boolean indicating if the JOB is done or not. See
148 DAG.load_rescue() for more information.
149
150 .noop
151 Boolean indicating if the JOB is a no-op or not.
152
153 .vars
154 A dictionary of the name-->value pairs in the VARS line for
155 the JOB. Leave empty to omit VARS from DAG.
156
157 .retry
158 The number of retries for the job. Set to None to omit
159 from DAG.
160
161 .retry_unless_exit_value
162 The value of the UNLESS-EXIT suffix of the RETRY line.
163 Set to None to omit from DAG.
164
165 .priority
166 .category
167 The PRIORITY value and CATEGORY name for the node in the
168 DAG. Set to None to omit from the DAG.
169
170 .parents
171 .children
172 Sets of the parent and child nodes of JOB. The sets
173 contain references to the node objects, not their names.
174
175 .prescript
176 .prescriptargs
177 .postscript
178 .postscriptargs
179 The names and lists of arguments of the PRE and POST
180 scripts. Set to None to omit from DAG.
181
182 .abort_dag_on_abortexitvalue
183 .abort_dag_on_dagreturnvalue
184 The ABORT-DAG-ON abort exit value and DAG return value for
185 the JOB. Set to None to omit from DAG.
186
187 For more information about the function of these parameters, refer
188 to the Condor documentation.
189 """
190 keyword = "JOB"
191
192 def __init__(self, name, filename, directory = None, done = False, noop = False):
193 # information from the JOB line in the DAG file
194 self.name = name
195 self.filename = filename
196 self.directory = directory
197 self.done = done
198 self.noop = noop
199
200 # the VARS line in the DAG file. orderless name, value
201 # pairs
202 self.vars = {}
203
204 # the RETRY line in the DAG file
205 self.retry = None
206 self.retry_unless_exit_value = None
207
208 # the PRIORITY and CATEGORY lines in the DAG file
209 self.priority = None
210 self.category = None
211
212 # the parents and children of this node. the sets contain
213 # references to the parent and child objects, not their
214 # names
215 self.parents = set()
216 self.children = set()
217
218 # the names and arguments of the PRE and POST scripts, if
219 # any
220 self.prescript = None
221 self.prescriptargs = None
222 self.postscript = None
223 self.postscriptargs = None
224
225 # the ABORT-DAG-ON abort exit value and dag return value
226 # for this job if they are set, or None if not
229
230 def write(self, f, progress = None):
231 """
232 Write the lines describing this node to the file-like
233 object f. The object must provide a .write() method.
234
235 If progress is not None, it will be incremented by 1 for
236 every line written.
237 """
238 # JOB ...
239 f.write("%s %s %s" % (self.keyword, self.name, self.filename))
240 if self.directory is not None:
241 f.write(" DIR \"%s\"" % self.directory)
242 if self.noop:
243 f.write(" NOOP")
244 if self.done:
245 f.write(" DONE")
246 f.write("\n")
247 if progress is not None:
248 progress += 1
249
250 # PRIORITY ...
251 if self.priority:
252 f.write("PRIORITY %s %d\n" % (self.name, self.priority))
253 if progress is not None:
254 progress += 1
255
256 # CATEGORY ...
257 if self.category is not None:
258 f.write("CATEGORY %s %s\n" % (self.name, self.category))
259 if progress is not None:
260 progress += 1
261
262 # RETRY ...
263 if self.retry:
264 f.write("RETRY %s %d" % (self.name, self.retry))
265 if self.retry_unless_exit_value is not None:
266 f.write(" UNLESS-EXIT %d" % self.retry_unless_exit_value)
267 f.write("\n")
268 if progress is not None:
269 progress += 1
270
271 # VARS ...
272 if self.vars:
273 f.write("VARS %s" % self.name)
274 for name, value in sorted(self.vars.items()):
275 # apply escape rules to the value
276 f.write(" %s=\"%s\"" % (name, value.replace("\\", "\\\\").replace("\"", "\\\"")))
277 f.write("\n")
278 if progress is not None:
279 progress += 1
280
281 # SCRIPT PRE ...
282 if self.prescript is not None:
283 f.write("SCRIPT PRE %s %s" % (self.name, self.prescript))
284 if self.prescriptargs:
285 f.write(" %s" % " ".join(self.prescriptargs))
286 f.write("\n")
287 if progress is not None:
288 progress += 1
289
290 # SCRIPT POST ...
291 if self.postscript is not None:
292 f.write("SCRIPT POST %s %s" % (self.name, self.postscript))
293 if self.postscriptargs:
294 f.write(" %s" % " ".join(self.postscriptargs))
295 f.write("\n")
296 if progress is not None:
297 progress += 1
298
299 # ABORT-DAG-ON ...
300 if self.abort_dag_on_abortexitvalue is not None:
301 f.write("ABORT-DAG-ON %s %d" % (self.name, self.abort_dag_on_abortexitvalue))
302 if self.abort_dag_on_dagreturnvalue is not None:
303 f.write(" RETURN %d" % self.abort_dag_on_dagreturnvalue)
304 f.write("\n")
305 if progress is not None:
306 progress += 1
307
308 # state
309 @property
310 def state(self):
311 """
312 Get the state of the node. One of 'wait', 'idle', 'run',
313 'abort', 'stop', 'success', 'fail'.
314
315 NOTE: this feature is not implemented at this time.
316 """
317 raise NotImplemented
318
319
320class DATA(JOB):
321 """
322 Representation of a Stork DATA node in a Condor DAG.
323 """
324 keyword = "DATA"
325
326
328 """
329 Representation of a SUBDAG EXTERNAL node in a Condor DAG.
330 """
331 keyword = "SUBDAG EXTERNAL"
332
333
334class SPLICE(JOB):
335 """
336 Representation of a SPLICE node in a Condor DAG.
337 """
338 # NOTE: although this is a subclass of the JOB class, splices
339 # don't support most of the things that can be associated with
340 # jobs, like VARS and so on, so don't set attributes that shouldn't
341 # be set or you'll get a nonsense DAG. In the future, more error
342 # checking might be added to prevent mis-use
343 keyword = "SPLICE"
344
345
346class DAG(object):
347 """
348 Representation of the contents of a Condor DAG file.
349
350 BUGS: the semantics of the "+" special character in category names
351 is not understood. For now, it is an error for a node's category
352 to not be found verbatim in a MAXJOBS line. The "+" character is a
353 wildcard-like character used in the assignment of MAXJOBS values to
354 job categories in splices; see the Condor documentation for more
355 information.
356 """
357
358 #
359 # lines in DAG files
360 #
361
362 dotpat = re.compile(r'^DOT\s+(?P<filename>\S+)(\s+(?P<options>.+))?', re.IGNORECASE)
363 jobpat = re.compile(r'^JOB\s+(?P<name>\S+)\s+(?P<filename>\S+)(\s+DIR\s+(?P<directory>\S+))?(\s+(?P<noop>NOOP))?(\s+(?P<done>DONE))?', re.IGNORECASE)
364 datapat = re.compile(r'^DATA\s+(?P<name>\S+)\s+(?P<filename>\S+)(\s+DIR\s+(?P<directory>\S+))?(\s+(?P<noop>NOOP))?(\s+(?P<done>DONE))?', re.IGNORECASE)
365 subdagpat = re.compile(r'^SUBDAG\s+EXTERNAL\s+(?P<name>\S+)\s+(?P<filename>\S+)(\s+DIR\s+(?P<directory>\S+))?(\s+(?P<noop>NOOP))?(\s+(?P<done>DONE))?', re.IGNORECASE)
366 splicepat = re.compile(r'^SPLICE\s+(?P<name>\S+)\s+(?P<filename>\S+)(\s+DIR\s+(?P<directory>\S+))?', re.IGNORECASE)
367 prioritypat = re.compile(r'^PRIORITY\s+(?P<name>\S+)\s+(?P<value>\S+)', re.IGNORECASE)
368 categorypat = re.compile(r'^CATEGORY\s+(?P<name>\S+)\s+(?P<category>\S+)', re.IGNORECASE)
369 retrypat = re.compile(r'^RETRY\s+(?P<name>\S+)\s+(?P<retries>\S+)(\s+UNLESS-EXIT\s+(?P<retry_unless_exit_value>\S+))?', re.IGNORECASE)
370 varspat = re.compile(r'^VARS\s+(?P<name>\S+)\s+(?P<vars>.+)', re.IGNORECASE)
371 varsvaluepat = re.compile(r'(?P<name>\S+)\s*=\s*"(?P<value>.*?)(?<!\\)"', re.IGNORECASE)
372 scriptpat = re.compile(r'^SCRIPT\s+(?P<type>(PRE)|(POST))\s(?P<name>\S+)\s+(?P<executable>\S+)(\s+(?P<arguments>.+))?', re.IGNORECASE)
373 abortdagonpat = re.compile(r'^ABORT-DAG-ON\s+(?P<name>\S+)\s+(?P<exitvalue>\S+)(\s+RETURN\s+(?P<returnvalue>\S+))?', re.IGNORECASE)
374 arcpat = re.compile(r'^PARENT\s+(?P<parents>.+?)\s+CHILD\s+(?P<children>.+)', re.IGNORECASE)
375 maxjobspat = re.compile(r'^MAXJOBS\s+(?P<category>\S+)\s+(?P<value>\S+)', re.IGNORECASE)
376 configpat = re.compile(r'^CONFIG\s+(?P<filename>\S+)', re.IGNORECASE)
377 nodestatuspat = re.compile(r'^NODE_STATUS_FILE\s+(?P<filename>\S+)(\s+(?P<updatetime>\S+))?', re.IGNORECASE)
378 jobstatepat = re.compile(r'^JOBSTATE_LOG\s+(?P<filename>\S+)', re.IGNORECASE)
379
380 #
381 # lines in rescue DAG files
382 #
383
384 donepat = re.compile(r'^DONE\s+(?P<name>\S+)', re.IGNORECASE)
385
386 #
387 # methods
388 #
389
390 def __init__(self, nodes = {}, maxjobs = {}, config = None, dot = None, dotupdate = False, dotoverwrite = True, dotinclude = None, node_status_file = None, node_status_file_updatetime = None, jobstate_log = None):
391 """
392 The meanings of the keyword arguments are:
393
394 nodes:
395 name --> JOB object mapping
396 maxjobs:
397 category name --> integer max jobs value mapping. all
398 categories are listed, that is it is an error for a JOB
399 in the DAG to claim to be in a category that cannot be
400 found in this dictionary. categories that don't have a
401 MAXJOBS set for them use None as their max jobs value
402 in this dictionary.
403 config:
404 filename or None
405 dot:
406 filename or None
407 dotupdate:
408 dotoverwrite:
409 booleans, defaults match Condor's
410 dotinclude:
411 filename or None
412 node_status_file:
413 node_status_file_updatetime:
414 filename and update time or None for both
415 jobstate_log:
416 filename or None
417
418 It is also possible to initialize a DAG object from another
419 DAG (-like) object with.
420
421 >> new = DAG(old)
422 """
423 # initialize from keyword arguments, will check to see if
424 # nodes was a DAG object afteward
425 self.nodes = nodes
426 self.maxjobs = maxjobs
427 self.config = config
428 self.dot = dot
429 self.dotupdate = dotupdate
430 self.dotoverwrite = dotoverwrite
431 self.dotinclude = dotinclude
432 self.node_status_file = node_status_file
433 self.node_status_file_updatetime = node_status_file_updatetime
434 self.jobstate_log = jobstate_log
435
436 try:
437 # is nodes a DAG object? test for this by trying
438 # to retrieve the attributes it would need if it
439 # is
440 dag = nodes
441 dag.nodes, dag.maxjobs, dag.config, dag.dot, dag.dotupdate, dag.dotoverwrite, dag.dotinclude, dag.node_status_file, dag.node_status_file_updatetime, dag.jobstate_log
442 except AttributeError:
443 # nope, it's not a proper DAG object
444 pass
445 else:
446 # that worked, so reinitialize ourselves from its
447 # attributes
448 # FIXME: maybe the JOB class can be taught to
449 # duplicate itself
450 self.nodes = dict((name, copy.copy(node)) for name, node in dag.nodes.items())
451 self.maxjobs = dict(dag.maxjobs)
452 self.config = dag.config
453 self.dot = dag.dot
454 self.dotupdate = dag.dotupdate
455 self.dotoverwrite = dag.dotoverwrite
456 self.dotinclude = dag.dotinclude
457 self.node_status_file = dag.node_status_file
458 self.node_status_file_updatetime = dag.node_status_file_updatetime
459 self.jobstate_log = dag.jobstate_log
460
461 def reindex(self):
462 """
463 Rebuild the .nodes index. This is required if the names of
464 nodes are changed.
465 """
466 # the .nodes object has its contents replaced instead of
467 # building a new object so that if external code is holding
468 # a reference to it that code sees the new index as well
469 nodes = dict((node.name, node) for node in self.nodes.values())
470 if len(nodes) != len(self.nodes):
471 raise ValueError("node names are not unique")
472 self.nodes.clear()
473 self.nodes.update(nodes)
474
475 @classmethod
476 def parse(cls, f, progress = None):
477 """
478 Parse the file-like object f as a Condor DAG file. Return
479 a DAG object. The file object must be iterable, yielding
480 one line of text of the DAG file in each iteration.
481
482 If the progress argument is not None, it should be a
483 callable object. This object will be called periodically
484 and passed the f argument, the current line number, and a
485 boolean indicating if parsing is complete. The boolean is
486 always False until parsing is complete, then the callable
487 will be invoked one last time with the final line count and
488 the boolean set to True.
489
490 Example:
491
492 >>> def progress(f, n, done):
493 ... print("reading %s: %d lines\\r" % (f.name, n)),
494 ... if done:
495 ... print
496 ...
497 >>> dag = DAG.parse(open("pipeline.dag"), progress = progress)
498 """
499 progress = progress_wrapper(f, progress)
500 self = cls()
501 arcs = []
502 for n, line in enumerate(f, start = 1):
503 # progress
504 progress += 1
505 # skip comments and blank lines
506 line = line.strip()
507 if not line or line.startswith("#"):
508 continue
509 # JOB ...
510 m = self.jobpat.search(line)
511 if m is not None:
512 if m.group("name") in self.nodes:
513 raise ValueError("line %d: duplicate JOB %s" % (n, m.group("name")))
514 self.nodes[m.group("name")] = JOB(m.group("name"), m.group("filename"), directory = m.group("directory") and m.group("directory").strip("\""), done = bool(m.group("done")), noop = bool(m.group("noop")))
515 continue
516 # DATA ...
517 m = self.datapat.search(line)
518 if m is not None:
519 if m.group("name") in self.nodes:
520 raise ValueError("line %d: duplicate DATA %s" % (n, m.group("name")))
521 self.nodes[m.group("name")] = DATA(m.group("name"), m.group("filename"), directory = m.group("directory") and m.group("directory").strip("\""), done = bool(m.group("done")), noop = bool(m.group("noop")))
522 continue
523 # SUBDAG EXTERNAL ...
524 m = self.subdagpat.search(line)
525 if m is not None:
526 if m.group("name") in self.nodes:
527 raise ValueError("line %d: duplicate SUBDAG EXTERNAL %s" % (n, m.group("name")))
528 self.nodes[m.group("name")] = SUBDAG_EXTERNAL(m.group("name"), m.group("filename"), directory = m.group("directory") and m.group("directory").strip("\""), done = bool(m.group("done")), noop = bool(m.group("noop")))
529 continue
530 # SPLICE ...
531 m = self.splicepat.search(line)
532 if m is not None:
533 if m.group("name") in self.nodes:
534 raise ValueError("line %d: duplicate SPLICE %s" % (n, m.group("name")))
535 self.nodes[m.group("name")] = SPLICE(m.group("name"), m.group("filename"), directory = m.group("directory") and m.group("directory").strip("\""))
536 continue
537 # VARS ...
538 m = self.varspat.search(line)
539 if m is not None:
540 node = self.nodes[m.group("name")]
541 # FIXME: find a way to detect malformed name=value pairs
542 for name, value in self.varsvaluepat.findall(m.group("vars")):
543 if name in node.vars:
544 raise ValueError("line %d: multiple variable %s for %s %s" % (n, name, node.keyword, node.name))
545 # apply unescape rules to the value
546 node.vars[name] = value.replace("\\\\", "\\").replace("\\\"", "\"")
547 continue
548 # PARENT ... CHILD ...
549 m = self.arcpat.search(line)
550 if m is not None:
551 parents = m.group("parents").strip().split()
552 children = m.group("children").strip().split()
553 arcs.extend((parent, child) for parent in parents for child in children)
554 continue
555 # RETRY ...
556 m = self.retrypat.search(line)
557 if m is not None:
558 node = self.nodes[m.group("name")]
559 node.retry = int(m.group("retries"))
560 node.retry_unless_exit_value = m.group("retry_unless_exit_value")
561 continue
562 # SCRIPT ...
563 m = self.scriptpat.search(line)
564 if m is not None:
565 node = self.nodes[m.group("name")]
566 if m.group("type").upper() == "PRE":
567 if node.prescript is not None:
568 raise ValueError("line %d: multiple SCRIPT PRE for %s %s" % (n, node.keyword, node.name))
569 node.prescript = m.group("executable")
570 if m.group("arguments") is not None:
571 node.prescriptargs = m.group("arguments").split()
572 elif m.group("type").upper() == "POST":
573 if node.postscript is not None:
574 raise ValueError("line %d: multiple SCRIPT POST for %s %s" % (n, node.keyword, node.name))
575 node.postscript = m.group("executable")
576 if m.group("arguments") is not None:
577 node.postscriptargs = m.group("arguments").split()
578 else:
579 assert False # impossible to get here
580 continue
581 # PRIORITY ...
582 m = self.prioritypat.search(line)
583 if m is not None:
584 node = self.nodes[m.group("name")]
585 if node.priority is not None:
586 raise ValueError("line %d: multiple PRIORITY for %s %s" % (n, node.keyword, node.name))
587 node.priority = int(m.group("value"))
588 continue
589 # CATEGORY ...
590 m = self.categorypat.search(line)
591 if m is not None:
592 self.nodes[m.group("name")].category = m.group("category")
593 continue
594 # ABORT-DAG-ON ...
595 m = self.abortdagonpat.search(line)
596 if m is not None:
597 node = self.nodes[m.group("name")]
598 if node.abort_dag_on_abortexitvalue is not None:
599 raise ValueError("line %d: multiple ABORT-DAG-ON for %s %s" % (n, node.keyword, node.name))
600 node.abort_dag_on_abortexitvalue = int(m.group("exitvalue"))
601 if m.group("returnvalue") is not None:
602 node.abort_dag_on_dagreturnvalue = int(m.group("returnvalue"))
603 continue
604 # MAXJOBS ...
605 m = self.maxjobspat.search(line)
606 if m is not None:
607 if m.group("category") in self.maxjobs:
608 raise ValueError("line %d: multiple MAXJOBS for category %s" % (n, m.group("category")))
609 self.maxjobs[m.group("category")] = int(m.group("value"))
610 continue
611 # DOT ...
612 m = self.dotpat.search(line)
613 if m is not None:
614 self.dot = m.group("filename")
615 options = (m.group("options") or "").split()
616 while options:
617 option = options.pop(0).upper()
618 if option == "UPDATE":
619 self.dotupdate = True
620 elif option == "DONT-UPDATE":
621 self.dotupdate = False
622 elif option == "OVERWRITE":
623 self.dotoverwrite = True
624 elif option == "DONT-OVERWRITE":
625 self.dotoverwrite = False
626 elif option == "INCLUDE":
627 try:
628 self.dotinclude = options.pop(0)
629 except IndexError:
630 raise ValueError("line %d: missing filename for INCLUDE option of DOT" % n)
631 else:
632 raise ValueError("unrecognized option %s for DOT" % option)
633 continue
634 # CONFIG ...
635 m = self.dotpat.search(line)
636 if m is not None:
637 if self.config is not None:
638 raise ValueError("line %d: multiple CONFIG lines in dag file" % n)
639 self.config = m.group("filename")
640 continue
641 # NODE_STATUS_FILE ...
642 m = self.nodestatuspat.search(line)
643 if m is not None:
644 if self.node_status_file is not None:
645 raise ValueError("line %d: multiple NODE_STATUS_FILE lines in dag file" % n)
646 self.node_status_file = m.group("filename")
647 if m.group(updatetime) is not None:
648 self.node_status_file_updatetime = int(m.group("updatetime"))
649 continue
650 # JOBSTATE_LOG ...
651 m = self.jobstatepat.search(line)
652 if m is not None:
653 # dagman allows more than one of these
654 # statements, ignoring all but the first
655 if self.jobstate_log is None:
656 self.jobstate_log = m.group("filename")
657 continue
658 # error
659 raise ValueError("line %d: invalid line in dag file: %s" % (n, line))
660 # progress
661 del progress
662 # populate parent and child sets
663 getnode = self.nodes.__getitem__
664 for parent, child in arcs:
665 parent = getnode(parent)
666 child = getnode(child)
667 parent.children.add(child)
668 child.parents.add(parent)
669 # make sure all categories are known
670 for node in self.nodes.values():
671 if node.category is not None and node.category not in self.maxjobs:
672 self.maxjobs[node.category] = None
673 # done
674 return self
675
676 @classmethod
677 def select_nodes_by_name(cls, dag, nodenames):
678 """
679 Construct a new DAG object containing only the nodes whose
680 names are in nodenames.
681
682 Example:
683
684 >>> names_to_rerun = set(["triggergen"])
685 >>> dag = DAG.select_nodes_by_name(dag, names_to_rerun | dag.get_all_parent_names(names_to_rerun))
686
687 NOTE: the new DAG object is given references to the node
688 (JOB, DATA, etc.) objects in the original DAG, not copies
689 of them. Therefore, editing the node objects, for example
690 modifying their parent or child sets, will affect both
691 DAGs. To obtain an independent DAG with its own node
692 objects, make a deepcopy of the object that is returned
693 (see the copy module in the Python standard library for
694 more information).
695
696 Example:
697
698 >>> import copy
699 >>> dag = copy.deepcopy(DAG.select_nodes_by_name(dag, names_to_rerun | dag.get_all_parent_names(names_to_rerun)))
700 """
701 self = cls(dag)
702 self.nodes = dict((name, node) for name, node in dag.nodes.items() if name in nodenames)
703 self.maxjobs = dict((category, dag.maxjobs[category]) for category in set(node.category for node in self.nodes.values() if node.category is not None))
704 return self
705
706 def get_all_parent_names(self, names):
707 """
708 Trace the DAG backward from the parents of the nodes whose
709 names are given to the head nodes, inclusively, and return
710 the set of the names of all nodes visited.
711
712 Example:
713
714 >>> all_parents = dag.get_all_parent_names(["triggergen"])
715 """
716 all_parent_names = set()
717 nodes_to_scan = set(self.nodes[name] for name in names)
718 while nodes_to_scan:
719 node = nodes_to_scan.pop()
720 nodes_to_scan |= node.parents
721 all_parent_names |= set(parent.name for parent in node.parents)
722 return all_parent_names
723
724 def get_all_child_names(self, names):
725 """
726 Trace the DAG forward from the children of the nodes whose
727 names are given to the leaf nodes, inclusively, and return
728 the set of the names of all nodes visited.
729
730 Example:
731
732 >>> all_children = dag.get_all_child_names(["triggergen"])
733 """
734 all_child_names = set()
735 nodes_to_scan = set(self.nodes[name] for name in names)
736 while nodes_to_scan:
737 node = nodes_to_scan.pop()
738 nodes_to_scan |= node.children
739 all_child_names |= set(child.name for child in node.children)
740 return all_child_names
741
742 def check_edges(self):
743 """
744 Check all graph edges for validity. Checks that each of
745 every node's children lists that node as a parent, and vice
746 versa, and that all nodes listed in the parent and child
747 sets of all nodes are contained in this DAG. Raises
748 ValueError if a problem is found, otherwise returns None.
749
750 Example:
751
752 >>> try:
753 ... dag.check_edges()
754 ... except ValueError as e:
755 ... print("edges are broken: %s" % str(e))
756 ... else:
757 ... print("all edges are OK")
758 ...
759 """
760 nodes = set(self.nodes.values())
761 for node in nodes:
762 for child in node.children:
763 if node not in child.parents:
764 raise ValueError("node %s is not a parent of its child %s" % (node.name, child.name))
765 if child not in nodes:
766 raise ValueError("node %s has child %s that is not in DAG" % (node.name, child.name))
767 for parent in node.parents:
768 if node not in parent.children:
769 raise ValueError("node %s is not a child of its parent %s" % (node.name, parent.name))
770 if parent not in nodes:
771 raise ValueError("node %s has parent %s that is not in DAG" % (node.name, parent.name))
772
773 def load_rescue(self, f, progress = None):
774 """
775 Parse the file-like object f as a rescue DAG, using the
776 DONE lines therein to set the job states of this DAG.
777
778 In the past, rescue DAGs were full copies of the original
779 DAG with the word DONE added to the JOB lines of completed
780 jobs. In version 7.7.2 of Condor, the default format of
781 rescue DAGs was changed to a condensed format consisting of
782 only the names of completed jobs and the number of retries
783 remaining for incomplete jobs. Currently Condor still
784 supports the original rescue DAG format, but the user must
785 set the DAGMAN_WRITE_PARTIAL_RESCUE config variable to
786 false to obtain one. This module does not directly support
787 the new format, however this method allows a new-style
788 rescue DAG to be parsed to set the states of the jobs in a
789 DAG. This, in effect, converts a new-style rescue DAG to
790 an old-style rescue DAG, allowing the result to be
791 manipulated as before.
792
793 If the progress argument is not None, it should be a
794 callable object. This object will be called periodically
795 and passed the f argument, the current line number, and a
796 boolean indicating if parsing is complete. The boolean is
797 always False until parsing is complete, then the callable
798 will be invoked one last time with the final line count and
799 the boolean set to True.
800 """
801 # set all jobs to "not done"
802 for job in self.nodes.values():
803 job.done = False
804 # now load rescue DAG, updating done and retries states
805 progress = progress_wrapper(f, progress)
806 for n, line in enumerate(f):
807 # lines are counted from 1, enumerate counts from 0
808 n += 1
809 # progress
810 progress += 1
811 # skip comments and blank lines
812 line = line.strip()
813 if not line or line.startswith("#"):
814 continue
815 # DONE ...
816 m = self.donepat.search(line)
817 if m is not None:
818 self.nodes[m.group("name")].done = True
819 continue
820 # RETRY ...
821 m = self.retrypat.search(line)
822 if m is not None:
823 node = self.nodes[m.group("name")]
824 node.retry = int(m.group("retries"))
825 node.retry_unless_exit_value = m.group("retry_unless_exit_value")
826 continue
827 # error
828 raise ValueError("line %d: invalid line in rescue file: %s" % (n, line))
829 # progress
830 del progress
831
832 def write(self, f, progress = None, rescue = None):
833 """
834 Write the DAG to the file-like object f. The object must
835 provide a .write() method. In the special case that the
836 optional rescue argument is not None (see below) then f can
837 be set to None and no DAG file will be written (just the
838 rescue DAG will be written).
839
840 If the progress argument is not None, it should be a
841 callable object. This object will be called periodically
842 and passed the f argument, the current line number, and a
843 boolean indicating if writing is complete. The boolean is
844 always False until writing is complete, then the callable
845 will be invoked one last time with the final line count and
846 the boolean set to True.
847
848 Example:
849
850 >>> def progress(f, n, done):
851 ... print "writing %s: %d lines\\r" % (f.name, n),
852 ... if done:
853 ... print
854 ...
855 >>> dag.write(open("pipeline.dag", "w"), progress = progress)
856
857 NOTE: when writing PARENT/CHILD graph edges, this method
858 will silently skip any node names that are not in this
859 DAG's graph. This is a convenience to simplify writing
860 DAGs constructed by the .select_nodes_by_name() class
861 method. If one wishes to check for broken parent/child
862 links before writing the DAG use the .check_edges() method.
863
864 If the optional rescue argument is not None, it must be a
865 file-like object providing a .write() method and the DONE
866 state of jobs will be written to this file instead of the
867 .dag (in the .dag all jobs will be marked not done).
868
869 Example:
870
871 >>> dag.write(open("pipeline.dag", "w"), rescue = open("pipeline.dag.rescue001", "w"))
872
873 NOTE: it is left as an exercise for the calling code to
874 ensure the name chosen for the rescue file is consistent
875 with the naming convention assumed by condor_dagman when it
876 starts up.
877 """
878 # initialize proegress report wrapper
879 progress = progress_wrapper(f, progress)
880
881 # if needed, create a dummy object to allow .write() method
882 # calls
883 if f is None and rescue is not None:
884 f = nofile()
885
886 # DOT ...
887 if self.dot is not None:
888 f.write("DOT %s" % self.dot)
889 if self.dotupdate:
890 f.write(" UPDATE")
891 if not self.dotoverwrite:
892 f.write(" DONT-OVERWRITE")
893 if self.dotinclude is not None:
894 f.write(" INCLUDE %s" % self.dotinclude)
895 f.write("\n")
896 progress += 1
897
898 # CONFIG ...
899 if self.config is not None:
900 f.write("CONFIG %s\n" % self.config)
901 progress += 1
902
903 # NODE_STATUS_FILE ...
904 if self.node_status_file is not None:
905 f.write("NODE_STATUS_FILE %s" % self.node_status_file)
906 if self.node_status_file_updatetime is not None:
907 f.write(" %d" % self.node_status_file_updatetime)
908 f.write("\n")
909 progress += 1
910
911 # JOBSTATE_LOG ...
912 if self.jobstate_log is not None:
913 f.write("JOBSTATE_LOG %s\n" % self.jobstate_log)
914 progress += 1
915
916 # MAXJOBS ...
917 if set(node.category for node in self.nodes.values() if node.category is not None) - set(self.maxjobs):
918 raise ValueError("no MAXJOBS statement(s) for node category(ies) %s" % ", ".join(sorted(set(node.category for node in self.nodes.values() if node.category is not None) - set(self.maxjobs))))
919 for name, value in sorted(self.maxjobs.items()):
920 if value is not None:
921 f.write("MAXJOBS %s %d\n" % (name, value))
922 progress += 1
923
924 # JOB/DATA/SUBDAG ... (and things that go with them)
925 for name, node in sorted(self.nodes.items()):
926 if rescue is not None:
927 if node.done:
928 rescue.write("DONE %s\n" % node.name)
929 # save done state, then clear
930 done = node.done
931 node.done = False
932 node.write(f, progress = progress)
933 if rescue is not None:
934 # restore done state
935 node.done = done
936
937 # PARENT ... CHILD ...
938 names = set(self.nodes)
939 parents_of = {}
940 for name, node in self.nodes.items():
941 parents_of.setdefault(frozenset(child.name for child in node.children) & names, set()).add(node.name)
942 for children, parents in parents_of.items():
943 if children:
944 f.write("PARENT %s CHILD %s\n" % (" ".join(sorted(parents)), " ".join(sorted(children))))
945 progress += 1
946
947 # progress
948 del progress
949
950 def dot_source(self, title = "DAG", rename = False, colour = "black", bgcolour = "#a3a3a3", statecolours = {'wait': 'yellow', 'idle': 'yellow', 'run': 'lightblue', 'abort': 'red', 'stop': 'red', 'success': 'green', 'fail': 'red'}):
951 """
952 Generator yielding a sequence of strings containing DOT
953 code to generate a visualization of the DAG graph. See
954 http://www.graphviz.org for more information.
955
956 title provides a title for the graph. If rename is True,
957 instead of using the names of the nodes for the node names
958 in the graph, numbers will be used instead. The numbers
959 are assigned to the nodes in alphabetical order by node
960 name. This might be required if the nodes have names that
961 are incompatible with the DOT syntax.
962
963 colour and bgcolour set the outline colour of the graph
964 nodes and the background colour for the graph respectively.
965 statecolours is a dictionary mapping node state (see the
966 .state attribute of the JOB class and its derivatives) to a
967 colour. Set statecolours to None to disable state-based
968 colouring of graph nodes.
969
970 Example:
971
972 >>> import sys
973 >>> sys.stdout.writelines(dag.dot_source(statecolours = None))
974
975 BUGS: the JOB class does not implement the ability to
976 retrieve the job state at this time, therefore it is always
977 necessary to set statecolours to None. This might change
978 in the future.
979 """
980 # set up renaming map
981
982 if rename:
983 namemap = dict((name, str(n)) for n, name in enumerate(sorted(self.nodes), start = 1))
984 else:
985 namemap = dict((name, name) for name in self.nodes)
986
987 # generate dot code
988
989 yield 'digraph "%s" {\nnode [color="%s", href="\\N"];\ngraph [bgcolor="%s"];\n' % (title, colour, bgcolour)
990 for node in self.nodes.values():
991 if statecolours is not None:
992 yield '"%s"[color="%s"];\n' % (namemap[node.name], statecolours[node.state])
993 for child in node.children:
994 yield '"%s" -> "%s";\n' % (namemap[node.name], namemap[child.name])
995 yield '}\n'
996
997 # done
998
999
1000def optimize(dag):
1001 # validate graph edges
1002 dag.check_edges()
1003
1004 # generate no-op jobs
1005 def noopgen(dag, submit_filename):
1006 used = frozenset(name for name in dag.nodes if name.startswith("NOOP"))
1007 for i in itertools.count():
1008 name = "NOOP%d" % i
1009 if name in used:
1010 continue
1011 noop = JOB(
1012 name = name,
1013 filename = submit_filename,
1014 noop = True
1015 )
1016 dag.nodes[noop.name] = noop
1017 yield noop
1018 noops = iter(noopgen(dag, "noop.submit"))
1019
1020 # visit each node, construct a set of each node's children, and
1021 # construct a look-up table mapping each unique such set to the set
1022 # of parents possessing that set of children. these are the
1023 # many-to-many parent-child relationships that become PARENT ...
1024 # CHILD ... lines in the .dag. internally dagman represents each
1025 # of these as a collection of parents*children objects (graph
1026 # edges), so what is one line of text in the .dag file requires a
1027 # quadratically large amount of ram in dagman.
1028 parents_of = {}
1029 for name, node in dag.nodes.items():
1030 parents_of.setdefault(frozenset(node.children), set()).add(node)
1031
1032 # to work around this scaling problem we insert no-op jobs between
1033 # the parents and children to replace the n*m edges with n+m edges
1034 # plus one new node.
1035 for children, parents in parents_of.items():
1036 if len(parents) < 3 or len(children) < 3 or len(parents) * len(children) < 25:
1037 # below this number of edges we don't bother
1038 continue
1039 noop = noops.next()
1040 noop.parents |= parents
1041 noop.children |= children
1042 for node in parents:
1043 node.children.clear()
1044 node.children.add(noop)
1045 for node in children:
1046 node.parents.clear()
1047 node.parents.add(noop)
select_nodes_by_name(cls, dag, nodenames)
Definition dagfile.py:677
parse(cls, f, progress=None)
Definition dagfile.py:476
get_all_child_names(self, names)
Definition dagfile.py:724
__init__(self, nodes={}, maxjobs={}, config=None, dot=None, dotupdate=False, dotoverwrite=True, dotinclude=None, node_status_file=None, node_status_file_updatetime=None, jobstate_log=None)
Definition dagfile.py:390
dot_source(self, title="DAG", rename=False, colour="black", bgcolour="#a3a3a3", statecolours={ 'wait':'yellow', 'idle':'yellow', 'run':'lightblue', 'abort':'red', 'stop':'red', 'success':'green', 'fail':'red'})
Definition dagfile.py:950
load_rescue(self, f, progress=None)
Definition dagfile.py:773
write(self, f, progress=None, rescue=None)
Definition dagfile.py:832
get_all_parent_names(self, names)
Definition dagfile.py:706
write(self, f, progress=None)
Definition dagfile.py:230