Coverage for python/lsst/ctrl/mpexec/cmdLineFwk.py: 15%
278 statements
« prev ^ index » next coverage.py v6.5.0, created at 2022-10-07 03:02 -0700
« prev ^ index » next coverage.py v6.5.0, created at 2022-10-07 03:02 -0700
1# This file is part of ctrl_mpexec.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (http://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <http://www.gnu.org/licenses/>.
22"""Module defining CmdLineFwk class and related methods.
23"""
25from __future__ import annotations
27__all__ = ["CmdLineFwk"]
29# -------------------------------
30# Imports of standard modules --
31# -------------------------------
32import copy
33import datetime
34import getpass
35import logging
36from collections.abc import Iterable, Sequence
37from types import SimpleNamespace
38from typing import Optional, Tuple
40from lsst.daf.butler import Butler, CollectionType, DatasetRef, Registry
41from lsst.daf.butler.registry import MissingCollectionError, RegistryDefaults
42from lsst.daf.butler.registry.wildcards import CollectionWildcard
43from lsst.pipe.base import (
44 GraphBuilder,
45 Instrument,
46 Pipeline,
47 PipelineDatasetTypes,
48 QuantumGraph,
49 TaskDef,
50 TaskFactory,
51 buildExecutionButler,
52)
53from lsst.utils import doImportType
55from . import util
56from .dotTools import graph2dot, pipeline2dot
57from .executionGraphFixup import ExecutionGraphFixup
58from .mpGraphExecutor import MPGraphExecutor
59from .preExecInit import PreExecInit
60from .singleQuantumExecutor import SingleQuantumExecutor
62# ----------------------------------
63# Local non-exported definitions --
64# ----------------------------------
66_LOG = logging.getLogger(__name__)
69class _OutputChainedCollectionInfo:
70 """A helper class for handling command-line arguments related to an output
71 `~lsst.daf.butler.CollectionType.CHAINED` collection.
73 Parameters
74 ----------
75 registry : `lsst.daf.butler.Registry`
76 Butler registry that collections will be added to and/or queried from.
77 name : `str`
78 Name of the collection given on the command line.
79 """
81 def __init__(self, registry: Registry, name: str):
82 self.name = name
83 try:
84 self.chain = tuple(registry.getCollectionChain(name))
85 self.exists = True
86 except MissingCollectionError:
87 self.chain = ()
88 self.exists = False
90 def __str__(self) -> str:
91 return self.name
93 name: str
94 """Name of the collection provided on the command line (`str`).
95 """
97 exists: bool
98 """Whether this collection already exists in the registry (`bool`).
99 """
101 chain: Tuple[str, ...]
102 """The definition of the collection, if it already exists (`tuple`[`str`]).
104 Empty if the collection does not already exist.
105 """
108class _OutputRunCollectionInfo:
109 """A helper class for handling command-line arguments related to an output
110 `~lsst.daf.butler.CollectionType.RUN` collection.
112 Parameters
113 ----------
114 registry : `lsst.daf.butler.Registry`
115 Butler registry that collections will be added to and/or queried from.
116 name : `str`
117 Name of the collection given on the command line.
118 """
120 def __init__(self, registry: Registry, name: str):
121 self.name = name
122 try:
123 actualType = registry.getCollectionType(name)
124 if actualType is not CollectionType.RUN:
125 raise TypeError(f"Collection '{name}' exists but has type {actualType.name}, not RUN.")
126 self.exists = True
127 except MissingCollectionError:
128 self.exists = False
130 name: str
131 """Name of the collection provided on the command line (`str`).
132 """
134 exists: bool
135 """Whether this collection already exists in the registry (`bool`).
136 """
139class _ButlerFactory:
140 """A helper class for processing command-line arguments related to input
141 and output collections.
143 Parameters
144 ----------
145 registry : `lsst.daf.butler.Registry`
146 Butler registry that collections will be added to and/or queried from.
148 args : `types.SimpleNamespace`
149 Parsed command-line arguments. The following attributes are used,
150 either at construction or in later methods.
152 ``output``
153 The name of a `~lsst.daf.butler.CollectionType.CHAINED`
154 input/output collection.
156 ``output_run``
157 The name of a `~lsst.daf.butler.CollectionType.RUN` input/output
158 collection.
160 ``extend_run``
161 A boolean indicating whether ``output_run`` should already exist
162 and be extended.
164 ``replace_run``
165 A boolean indicating that (if `True`) ``output_run`` should already
166 exist but will be removed from the output chained collection and
167 replaced with a new one.
169 ``prune_replaced``
170 A boolean indicating whether to prune the replaced run (requires
171 ``replace_run``).
173 ``inputs``
174 Input collections of any type; see
175 :ref:`daf_butler_ordered_collection_searches` for details.
177 ``butler_config``
178 Path to a data repository root or configuration file.
180 writeable : `bool`
181 If `True`, a `Butler` is being initialized in a context where actual
182 writes should happens, and hence no output run is necessary.
184 Raises
185 ------
186 ValueError
187 Raised if ``writeable is True`` but there are no output collections.
188 """
190 def __init__(self, registry: Registry, args: SimpleNamespace, writeable: bool):
191 if args.output is not None:
192 self.output = _OutputChainedCollectionInfo(registry, args.output)
193 else:
194 self.output = None
195 if args.output_run is not None:
196 self.outputRun = _OutputRunCollectionInfo(registry, args.output_run)
197 elif self.output is not None:
198 if args.extend_run:
199 if not self.output.chain:
200 raise ValueError("Cannot use --extend-run option with non-existing or empty output chain")
201 runName = self.output.chain[0]
202 else:
203 runName = "{}/{}".format(self.output, Instrument.makeCollectionTimestamp())
204 self.outputRun = _OutputRunCollectionInfo(registry, runName)
205 elif not writeable:
206 # If we're not writing yet, ok to have no output run.
207 self.outputRun = None
208 else:
209 raise ValueError("Cannot write without at least one of (--output, --output-run).")
210 # Recursively flatten any input CHAINED collections. We do this up
211 # front so we can tell if the user passes the same inputs on subsequent
212 # calls, even though we also flatten when we define the output CHAINED
213 # collection.
214 self.inputs = tuple(registry.queryCollections(args.input, flattenChains=True)) if args.input else ()
216 def check(self, args: SimpleNamespace) -> None:
217 """Check command-line options for consistency with each other and the
218 data repository.
220 Parameters
221 ----------
222 args : `types.SimpleNamespace`
223 Parsed command-line arguments. See class documentation for the
224 construction parameter of the same name.
225 """
226 assert not (args.extend_run and args.replace_run), "In mutually-exclusive group in ArgumentParser."
227 if self.inputs and self.output is not None and self.output.exists:
228 # Passing the same inputs that were used to initialize the output
229 # collection is allowed; this means they must _end_ with the same
230 # collections, because we push new runs to the front of the chain.
231 for c1, c2 in zip(self.inputs[::-1], self.output.chain[::-1]):
232 if c1 != c2:
233 raise ValueError(
234 f"Output CHAINED collection {self.output.name!r} exists, but it ends with "
235 "a different sequence of input collections than those given: "
236 f"{c1!r} != {c2!r} in inputs={self.inputs} vs "
237 f"{self.output.name}={self.output.chain}."
238 )
239 if len(self.inputs) > len(self.output.chain):
240 nNew = len(self.inputs) - len(self.output.chain)
241 raise ValueError(
242 f"Cannot add new input collections {self.inputs[:nNew]} after "
243 "output collection is first created."
244 )
245 if args.extend_run:
246 if self.outputRun is None:
247 raise ValueError("Cannot --extend-run when no output collection is given.")
248 elif not self.outputRun.exists:
249 raise ValueError(
250 f"Cannot --extend-run; output collection '{self.outputRun.name}' does not exist."
251 )
252 if not args.extend_run and self.outputRun is not None and self.outputRun.exists:
253 raise ValueError(
254 f"Output run '{self.outputRun.name}' already exists, but --extend-run was not given."
255 )
256 if args.prune_replaced and not args.replace_run:
257 raise ValueError("--prune-replaced requires --replace-run.")
258 if args.replace_run and (self.output is None or not self.output.exists):
259 raise ValueError("--output must point to an existing CHAINED collection for --replace-run.")
261 @classmethod
262 def _makeReadParts(cls, args: SimpleNamespace) -> tuple[Butler, Sequence[str], _ButlerFactory]:
263 """Common implementation for `makeReadButler` and
264 `makeButlerAndCollections`.
266 Parameters
267 ----------
268 args : `types.SimpleNamespace`
269 Parsed command-line arguments. See class documentation for the
270 construction parameter of the same name.
272 Returns
273 -------
274 butler : `lsst.daf.butler.Butler`
275 A read-only butler constructed from the repo at
276 ``args.butler_config``, but with no default collections.
277 inputs : `Sequence` [ `str` ]
278 A collection search path constructed according to ``args``.
279 self : `_ButlerFactory`
280 A new `_ButlerFactory` instance representing the processed version
281 of ``args``.
282 """
283 butler = Butler(args.butler_config, writeable=False)
284 self = cls(butler.registry, args, writeable=False)
285 self.check(args)
286 if self.output and self.output.exists:
287 if args.replace_run:
288 replaced = self.output.chain[0]
289 inputs = list(self.output.chain[1:])
290 _LOG.debug(
291 "Simulating collection search in '%s' after removing '%s'.", self.output.name, replaced
292 )
293 else:
294 inputs = [self.output.name]
295 else:
296 inputs = list(self.inputs)
297 if args.extend_run:
298 assert self.outputRun is not None, "Output collection has to be specified."
299 inputs.insert(0, self.outputRun.name)
300 collSearch = CollectionWildcard.from_expression(inputs).require_ordered()
301 return butler, collSearch, self
303 @classmethod
304 def makeReadButler(cls, args: SimpleNamespace) -> Butler:
305 """Construct a read-only butler according to the given command-line
306 arguments.
308 Parameters
309 ----------
310 args : `types.SimpleNamespace`
311 Parsed command-line arguments. See class documentation for the
312 construction parameter of the same name.
314 Returns
315 -------
316 butler : `lsst.daf.butler.Butler`
317 A read-only butler initialized with the collections specified by
318 ``args``.
319 """
320 butler, inputs, _ = cls._makeReadParts(args)
321 _LOG.debug("Preparing butler to read from %s.", inputs)
322 return Butler(butler=butler, collections=inputs)
324 @classmethod
325 def makeButlerAndCollections(cls, args: SimpleNamespace) -> Tuple[Butler, Sequence[str], Optional[str]]:
326 """Return a read-only registry, a collection search path, and the name
327 of the run to be used for future writes.
329 Parameters
330 ----------
331 args : `types.SimpleNamespace`
332 Parsed command-line arguments. See class documentation for the
333 construction parameter of the same name.
335 Returns
336 -------
337 butler : `lsst.daf.butler.Butler`
338 A read-only butler that collections will be added to and/or queried
339 from.
340 inputs : `Sequence` [ `str` ]
341 Collections to search for datasets.
342 run : `str` or `None`
343 Name of the output `~lsst.daf.butler.CollectionType.RUN` collection
344 if it already exists, or `None` if it does not.
345 """
346 butler, inputs, self = cls._makeReadParts(args)
347 run: Optional[str] = None
348 if args.extend_run:
349 assert self.outputRun is not None, "Output collection has to be specified."
350 run = self.outputRun.name
351 _LOG.debug("Preparing registry to read from %s and expect future writes to '%s'.", inputs, run)
352 return butler, inputs, run
354 @classmethod
355 def makeWriteButler(cls, args: SimpleNamespace, taskDefs: Optional[Iterable[TaskDef]] = None) -> Butler:
356 """Return a read-write butler initialized to write to and read from
357 the collections specified by the given command-line arguments.
359 Parameters
360 ----------
361 args : `types.SimpleNamespace`
362 Parsed command-line arguments. See class documentation for the
363 construction parameter of the same name.
364 taskDefs : iterable of `TaskDef`, optional
365 Definitions for tasks in a pipeline. This argument is only needed
366 if ``args.replace_run`` is `True` and ``args.prune_replaced`` is
367 "unstore".
369 Returns
370 -------
371 butler : `lsst.daf.butler.Butler`
372 A read-write butler initialized according to the given arguments.
373 """
374 butler = Butler(args.butler_config, writeable=True)
375 self = cls(butler.registry, args, writeable=True)
376 self.check(args)
377 assert self.outputRun is not None, "Output collection has to be specified." # for mypy
378 if self.output is not None:
379 chainDefinition = list(self.output.chain if self.output.exists else self.inputs)
380 if args.replace_run:
381 replaced = chainDefinition.pop(0)
382 if args.prune_replaced == "unstore":
383 # Remove datasets from datastore
384 with butler.transaction():
385 refs: Iterable[DatasetRef] = butler.registry.queryDatasets(..., collections=replaced)
386 # we want to remove regular outputs but keep
387 # initOutputs, configs, and versions.
388 if taskDefs is not None:
389 initDatasetNames = set(PipelineDatasetTypes.initOutputNames(taskDefs))
390 refs = [ref for ref in refs if ref.datasetType.name not in initDatasetNames]
391 butler.pruneDatasets(refs, unstore=True, disassociate=False)
392 elif args.prune_replaced == "purge":
393 # Erase entire collection and all datasets, need to remove
394 # collection from its chain collection first.
395 with butler.transaction():
396 butler.registry.setCollectionChain(self.output.name, chainDefinition, flatten=True)
397 butler.pruneCollection(replaced, purge=True, unstore=True)
398 elif args.prune_replaced is not None:
399 raise NotImplementedError(f"Unsupported --prune-replaced option '{args.prune_replaced}'.")
400 if not self.output.exists:
401 butler.registry.registerCollection(self.output.name, CollectionType.CHAINED)
402 if not args.extend_run:
403 butler.registry.registerCollection(self.outputRun.name, CollectionType.RUN)
404 chainDefinition.insert(0, self.outputRun.name)
405 butler.registry.setCollectionChain(self.output.name, chainDefinition, flatten=True)
406 _LOG.debug(
407 "Preparing butler to write to '%s' and read from '%s'=%s",
408 self.outputRun.name,
409 self.output.name,
410 chainDefinition,
411 )
412 butler.registry.defaults = RegistryDefaults(run=self.outputRun.name, collections=self.output.name)
413 else:
414 inputs = (self.outputRun.name,) + self.inputs
415 _LOG.debug("Preparing butler to write to '%s' and read from %s.", self.outputRun.name, inputs)
416 butler.registry.defaults = RegistryDefaults(run=self.outputRun.name, collections=inputs)
417 return butler
419 output: Optional[_OutputChainedCollectionInfo]
420 """Information about the output chained collection, if there is or will be
421 one (`_OutputChainedCollectionInfo` or `None`).
422 """
424 outputRun: Optional[_OutputRunCollectionInfo]
425 """Information about the output run collection, if there is or will be
426 one (`_OutputRunCollectionInfo` or `None`).
427 """
429 inputs: Tuple[str, ...]
430 """Input collections provided directly by the user (`tuple` [ `str` ]).
431 """
434# ------------------------
435# Exported definitions --
436# ------------------------
439class CmdLineFwk:
440 """PipelineTask framework which executes tasks from command line.
442 In addition to executing tasks this activator provides additional methods
443 for task management like dumping configuration or execution chain.
444 """
446 MP_TIMEOUT = 3600 * 24 * 30 # Default timeout (sec) for multiprocessing
448 def __init__(self) -> None:
449 pass
451 def makePipeline(self, args: SimpleNamespace) -> Pipeline:
452 """Build a pipeline from command line arguments.
454 Parameters
455 ----------
456 args : `types.SimpleNamespace`
457 Parsed command line
459 Returns
460 -------
461 pipeline : `~lsst.pipe.base.Pipeline`
462 """
463 if args.pipeline:
464 pipeline = Pipeline.from_uri(args.pipeline)
465 else:
466 pipeline = Pipeline("anonymous")
468 # loop over all pipeline actions and apply them in order
469 for action in args.pipeline_actions:
470 if action.action == "add_instrument":
472 pipeline.addInstrument(action.value)
474 elif action.action == "new_task":
476 pipeline.addTask(action.value, action.label)
478 elif action.action == "delete_task":
480 pipeline.removeTask(action.label)
482 elif action.action == "config":
484 # action value string is "field=value", split it at '='
485 field, _, value = action.value.partition("=")
486 pipeline.addConfigOverride(action.label, field, value)
488 elif action.action == "configfile":
490 pipeline.addConfigFile(action.label, action.value)
492 else:
494 raise ValueError(f"Unexpected pipeline action: {action.action}")
496 if args.save_pipeline:
497 pipeline.write_to_uri(args.save_pipeline)
499 if args.pipeline_dot:
500 pipeline2dot(pipeline, args.pipeline_dot)
502 return pipeline
504 def makeGraph(self, pipeline: Pipeline, args: SimpleNamespace) -> Optional[QuantumGraph]:
505 """Build a graph from command line arguments.
507 Parameters
508 ----------
509 pipeline : `~lsst.pipe.base.Pipeline`
510 Pipeline, can be empty or ``None`` if graph is read from a file.
511 args : `types.SimpleNamespace`
512 Parsed command line
514 Returns
515 -------
516 graph : `~lsst.pipe.base.QuantumGraph` or `None`
517 If resulting graph is empty then `None` is returned.
518 """
520 # make sure that --extend-run always enables --skip-existing
521 if args.extend_run:
522 args.skip_existing = True
524 butler, collections, run = _ButlerFactory.makeButlerAndCollections(args)
526 if args.skip_existing and run:
527 args.skip_existing_in += (run,)
529 if args.qgraph:
530 # click passes empty tuple as default value for qgraph_node_id
531 nodes = args.qgraph_node_id or None
532 qgraph = QuantumGraph.loadUri(
533 args.qgraph, butler.registry.dimensions, nodes=nodes, graphID=args.qgraph_id
534 )
536 # pipeline can not be provided in this case
537 if pipeline:
538 raise ValueError("Pipeline must not be given when quantum graph is read from file.")
539 if args.show_qgraph_header:
540 print(QuantumGraph.readHeader(args.qgraph))
541 else:
542 # make execution plan (a.k.a. DAG) for pipeline
543 graphBuilder = GraphBuilder(
544 butler.registry,
545 skipExistingIn=args.skip_existing_in,
546 clobberOutputs=args.clobber_outputs,
547 datastore=butler.datastore if args.qgraph_datastore_records else None,
548 )
549 # accumulate metadata
550 metadata = {
551 "input": args.input,
552 "output": args.output,
553 "butler_argument": args.butler_config,
554 "output_run": args.output_run,
555 "extend_run": args.extend_run,
556 "skip_existing_in": args.skip_existing_in,
557 "skip_existing": args.skip_existing,
558 "data_query": args.data_query,
559 "user": getpass.getuser(),
560 "time": f"{datetime.datetime.now()}",
561 }
562 qgraph = graphBuilder.makeGraph(
563 pipeline,
564 collections,
565 run,
566 args.data_query,
567 metadata=metadata,
568 datasetQueryConstraint=args.dataset_query_constraint,
569 )
570 if args.show_qgraph_header:
571 qgraph.buildAndPrintHeader()
573 # Count quanta in graph and give a warning if it's empty and return
574 # None.
575 nQuanta = len(qgraph)
576 if nQuanta == 0:
577 return None
578 else:
579 _LOG.info(
580 "QuantumGraph contains %d quanta for %d tasks, graph ID: %r",
581 nQuanta,
582 len(qgraph.taskGraph),
583 qgraph.graphID,
584 )
586 if args.save_qgraph:
587 qgraph.saveUri(args.save_qgraph)
589 if args.save_single_quanta:
590 for quantumNode in qgraph:
591 sqgraph = qgraph.subset(quantumNode)
592 uri = args.save_single_quanta.format(quantumNode)
593 sqgraph.saveUri(uri)
595 if args.qgraph_dot:
596 graph2dot(qgraph, args.qgraph_dot)
598 if args.execution_butler_location:
599 butler = Butler(args.butler_config)
600 newArgs = copy.deepcopy(args)
602 def builderShim(butler: Butler) -> Butler:
603 newArgs.butler_config = butler._config
604 # Calling makeWriteButler is done for the side effects of
605 # calling that method, maining parsing all the args into
606 # collection names, creating collections, etc.
607 newButler = _ButlerFactory.makeWriteButler(newArgs)
608 return newButler
610 # Include output collection in collections for input
611 # files if it exists in the repo.
612 all_inputs = args.input
613 if args.output is not None:
614 try:
615 all_inputs += (next(iter(butler.registry.queryCollections(args.output))),)
616 except MissingCollectionError:
617 pass
619 _LOG.debug("Calling buildExecutionButler with collections=%s", all_inputs)
620 buildExecutionButler(
621 butler,
622 qgraph,
623 args.execution_butler_location,
624 run,
625 butlerModifier=builderShim,
626 collections=all_inputs,
627 clobber=args.clobber_execution_butler,
628 datastoreRoot=args.target_datastore_root,
629 transfer=args.transfer,
630 )
632 return qgraph
634 def runPipeline(
635 self,
636 graph: QuantumGraph,
637 taskFactory: TaskFactory,
638 args: SimpleNamespace,
639 butler: Optional[Butler] = None,
640 ) -> None:
641 """Execute complete QuantumGraph.
643 Parameters
644 ----------
645 graph : `QuantumGraph`
646 Execution graph.
647 taskFactory : `~lsst.pipe.base.TaskFactory`
648 Task factory
649 args : `types.SimpleNamespace`
650 Parsed command line
651 butler : `~lsst.daf.butler.Butler`, optional
652 Data Butler instance, if not defined then new instance is made
653 using command line options.
654 """
655 # make sure that --extend-run always enables --skip-existing
656 if args.extend_run:
657 args.skip_existing = True
659 # make butler instance
660 if butler is None:
661 butler = _ButlerFactory.makeWriteButler(args, graph.iterTaskGraph())
663 if args.skip_existing:
664 args.skip_existing_in += (butler.run,)
666 # Enable lsstDebug debugging. Note that this is done once in the
667 # main process before PreExecInit and it is also repeated before
668 # running each task in SingleQuantumExecutor (which may not be
669 # needed if `multipocessing` always uses fork start method).
670 if args.enableLsstDebug:
671 try:
672 _LOG.debug("Will try to import debug.py")
673 import debug # type: ignore # noqa:F401
674 except ImportError:
675 _LOG.warn("No 'debug' module found.")
677 # Save all InitOutputs, configs, etc.
678 preExecInit = PreExecInit(butler, taskFactory, extendRun=args.extend_run, mock=args.mock)
679 preExecInit.initialize(
680 graph,
681 saveInitOutputs=not args.skip_init_writes,
682 registerDatasetTypes=args.register_dataset_types,
683 saveVersions=not args.no_versions,
684 )
686 if not args.init_only:
687 graphFixup = self._importGraphFixup(args)
688 quantumExecutor = SingleQuantumExecutor(
689 taskFactory,
690 skipExistingIn=args.skip_existing_in,
691 clobberOutputs=args.clobber_outputs,
692 enableLsstDebug=args.enableLsstDebug,
693 exitOnKnownError=args.fail_fast,
694 mock=args.mock,
695 mock_configs=args.mock_configs,
696 )
697 timeout = self.MP_TIMEOUT if args.timeout is None else args.timeout
698 executor = MPGraphExecutor(
699 numProc=args.processes,
700 timeout=timeout,
701 startMethod=args.start_method,
702 quantumExecutor=quantumExecutor,
703 failFast=args.fail_fast,
704 pdb=args.pdb,
705 executionGraphFixup=graphFixup,
706 )
707 try:
708 with util.profile(args.profile, _LOG):
709 executor.execute(graph, butler)
710 finally:
711 if args.summary:
712 report = executor.getReport()
713 if report:
714 with open(args.summary, "w") as out:
715 # Do not save fields that are not set.
716 out.write(report.json(exclude_none=True, indent=2))
718 def _importGraphFixup(self, args: SimpleNamespace) -> Optional[ExecutionGraphFixup]:
719 """Import/instantiate graph fixup object.
721 Parameters
722 ----------
723 args : `types.SimpleNamespace`
724 Parsed command line.
726 Returns
727 -------
728 fixup : `ExecutionGraphFixup` or `None`
730 Raises
731 ------
732 ValueError
733 Raised if import fails, method call raises exception, or returned
734 instance has unexpected type.
735 """
736 if args.graph_fixup:
737 try:
738 factory = doImportType(args.graph_fixup)
739 except Exception as exc:
740 raise ValueError("Failed to import graph fixup class/method") from exc
741 try:
742 fixup = factory()
743 except Exception as exc:
744 raise ValueError("Failed to make instance of graph fixup") from exc
745 if not isinstance(fixup, ExecutionGraphFixup):
746 raise ValueError("Graph fixup is not an instance of ExecutionGraphFixup class")
747 return fixup
748 return None