ceci 2.2__tar.gz → 2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. {ceci-2.2 → ceci-2.3}/.gitignore +0 -1
  2. {ceci-2.2 → ceci-2.3}/PKG-INFO +7 -7
  3. ceci-2.3/ceci/__init__.py +18 -0
  4. ceci-2.3/ceci/_version.py +34 -0
  5. {ceci-2.2 → ceci-2.3}/ceci/main.py +13 -51
  6. {ceci-2.2 → ceci-2.3}/ceci/minirunner.py +1 -1
  7. ceci-2.3/ceci/pipeline/__init__.py +15 -0
  8. ceci-2.3/ceci/pipeline/dry_run.py +43 -0
  9. ceci-2.3/ceci/pipeline/file_manager.py +73 -0
  10. ceci-2.3/ceci/pipeline/flow_chart.py +8 -0
  11. ceci-2.3/ceci/pipeline/graph.py +199 -0
  12. ceci-2.3/ceci/pipeline/mini.py +133 -0
  13. ceci-2.3/ceci/pipeline/parsl.py +169 -0
  14. ceci-2.3/ceci/pipeline/pipeline.py +954 -0
  15. ceci-2.3/ceci/pipeline/sec.py +173 -0
  16. ceci-2.3/ceci/pipeline/templates.py +54 -0
  17. {ceci-2.2 → ceci-2.3}/ceci/sites/__init__.py +1 -1
  18. {ceci-2.2 → ceci-2.3}/ceci/sites/ccin2p3.py +0 -3
  19. {ceci-2.2 → ceci-2.3}/ceci/sites/local.py +0 -3
  20. {ceci-2.2 → ceci-2.3}/ceci/sites/nersc.py +0 -3
  21. {ceci-2.2 → ceci-2.3}/ceci/sites/site.py +0 -1
  22. {ceci-2.2 → ceci-2.3}/ceci/stage.py +1 -144
  23. ceci-2.3/ceci/tools/ancestors.py +31 -0
  24. {ceci-2.2 → ceci-2.3}/ceci.egg-info/PKG-INFO +7 -7
  25. {ceci-2.2 → ceci-2.3}/ceci.egg-info/SOURCES.txt +13 -1
  26. {ceci-2.2 → ceci-2.3}/ceci.egg-info/requires.txt +4 -6
  27. {ceci-2.2 → ceci-2.3}/docs/config.rst +3 -3
  28. {ceci-2.2 → ceci-2.3}/docs/launchers.rst +1 -23
  29. {ceci-2.2 → ceci-2.3}/docs/stages.rst +1 -1
  30. ceci-2.3/nb/ceci_interactive_example.ipynb +567 -0
  31. {ceci-2.2 → ceci-2.3}/pyproject.toml +5 -6
  32. ceci-2.3/tests/north.yml +9 -0
  33. ceci-2.3/tests/template.yml +93 -0
  34. {ceci-2.2 → ceci-2.3}/tests/test.yml +0 -5
  35. {ceci-2.2 → ceci-2.3}/tests/test_dataregistry.yml +0 -5
  36. {ceci-2.2 → ceci-2.3}/tests/test_executables.py +12 -1
  37. ceci-2.3/tests/test_graph.py +99 -0
  38. ceci-2.3/tests/test_main.py +190 -0
  39. {ceci-2.2 → ceci-2.3}/tests/test_namespace.yml +0 -5
  40. {ceci-2.2 → ceci-2.3}/tests/test_pipeline.py +85 -19
  41. {ceci-2.2 → ceci-2.3}/tests/test_snapshot.py +6 -0
  42. ceci-2.2/ceci/__init__.py +0 -19
  43. ceci-2.2/ceci/_version.py +0 -16
  44. ceci-2.2/ceci/pipeline.py +0 -1744
  45. ceci-2.2/ceci/tools/ancestors.py +0 -50
  46. ceci-2.2/nb/ceci_interactive_example.ipynb +0 -314
  47. ceci-2.2/tests/test_main.py +0 -103
  48. {ceci-2.2 → ceci-2.3}/.coveragerc +0 -0
  49. {ceci-2.2 → ceci-2.3}/.github/workflows/ci.yml +0 -0
  50. {ceci-2.2 → ceci-2.3}/.github/workflows/publish.yml +0 -0
  51. {ceci-2.2 → ceci-2.3}/.pylintrc +0 -0
  52. {ceci-2.2 → ceci-2.3}/LICENSE +0 -0
  53. {ceci-2.2 → ceci-2.3}/README.md +0 -0
  54. {ceci-2.2 → ceci-2.3}/ceci/__main__.py +0 -0
  55. {ceci-2.2 → ceci-2.3}/ceci/config.py +0 -0
  56. {ceci-2.2 → ceci-2.3}/ceci/errors.py +0 -0
  57. {ceci-2.2 → ceci-2.3}/ceci/monitor.py +0 -0
  58. {ceci-2.2 → ceci-2.3}/ceci/tools/__init__.py +0 -0
  59. {ceci-2.2 → ceci-2.3}/ceci/update_pipelines_for_ceci_2.py +0 -0
  60. {ceci-2.2 → ceci-2.3}/ceci/utils.py +0 -0
  61. {ceci-2.2 → ceci-2.3}/ceci.egg-info/dependency_links.txt +0 -0
  62. {ceci-2.2 → ceci-2.3}/ceci.egg-info/entry_points.txt +0 -0
  63. {ceci-2.2 → ceci-2.3}/ceci.egg-info/top_level.txt +0 -0
  64. {ceci-2.2 → ceci-2.3}/ceci.png +0 -0
  65. {ceci-2.2 → ceci-2.3}/ceci_example/__init__.py +0 -0
  66. {ceci-2.2 → ceci-2.3}/ceci_example/__main__.py +0 -0
  67. {ceci-2.2 → ceci-2.3}/ceci_example/example_stages.py +0 -0
  68. {ceci-2.2 → ceci-2.3}/ceci_example/types.py +0 -0
  69. {ceci-2.2 → ceci-2.3}/ceci_example_namespace/example_stages.py +0 -0
  70. {ceci-2.2 → ceci-2.3}/do_cover.sh +0 -0
  71. {ceci-2.2 → ceci-2.3}/docs/Makefile +0 -0
  72. {ceci-2.2 → ceci-2.3}/docs/conf.py +0 -0
  73. {ceci-2.2 → ceci-2.3}/docs/config2.rst +0 -0
  74. {ceci-2.2 → ceci-2.3}/docs/index.rst +0 -0
  75. {ceci-2.2 → ceci-2.3}/docs/installation.rst +0 -0
  76. {ceci-2.2 → ceci-2.3}/docs/logo.png +0 -0
  77. {ceci-2.2 → ceci-2.3}/docs/overview.rst +0 -0
  78. {ceci-2.2 → ceci-2.3}/docs/sites.rst +0 -0
  79. {ceci-2.2 → ceci-2.3}/docs/tutorial.rst +0 -0
  80. {ceci-2.2 → ceci-2.3}/setup.cfg +0 -0
  81. {ceci-2.2 → ceci-2.3}/setup.py +0 -0
  82. {ceci-2.2 → ceci-2.3}/tests/__init__.py +0 -0
  83. {ceci-2.2 → ceci-2.3}/tests/config.yml +0 -0
  84. {ceci-2.2 → ceci-2.3}/tests/config_nm.yml +0 -0
  85. {ceci-2.2 → ceci-2.3}/tests/create_registry_entries.py +0 -0
  86. {ceci-2.2 → ceci-2.3}/tests/inputs/dm.txt +0 -0
  87. {ceci-2.2 → ceci-2.3}/tests/inputs/fiducial_cosmology.txt +0 -0
  88. {ceci-2.2 → ceci-2.3}/tests/test.hdf5 +0 -0
  89. {ceci-2.2 → ceci-2.3}/tests/test_cmd.py +0 -0
  90. {ceci-2.2 → ceci-2.3}/tests/test_dask.py +0 -0
  91. {ceci-2.2 → ceci-2.3}/tests/test_flow_chart.py +0 -0
  92. {ceci-2.2 → ceci-2.3}/tests/test_helpers.py +0 -0
  93. {ceci-2.2 → ceci-2.3}/tests/test_interactive.py +0 -0
  94. {ceci-2.2 → ceci-2.3}/tests/test_minirunner.py +0 -0
  95. {ceci-2.2 → ceci-2.3}/tests/test_monitor.py +0 -0
  96. {ceci-2.2 → ceci-2.3}/tests/test_python_paths.py +0 -0
  97. {ceci-2.2 → ceci-2.3}/tests/test_site.py +0 -0
  98. {ceci-2.2 → ceci-2.3}/tests/test_stage.py +0 -0
  99. {ceci-2.2 → ceci-2.3}/tests/test_update_script.py +0 -0
  100. {ceci-2.2 → ceci-2.3}/tests/test_utils.py +0 -0
  101. {ceci-2.2 → ceci-2.3}/tests/update_test_files/config.yml +0 -0
  102. {ceci-2.2 → ceci-2.3}/tests/update_test_files/pipeline1.yml +0 -0
  103. {ceci-2.2 → ceci-2.3}/tests/update_test_files/pipeline2.yml +0 -0
@@ -9,7 +9,6 @@ dist
9
9
  log.txt
10
10
  test_save*
11
11
  tests/log*
12
- tests/cwl
13
12
  nb/Untitled.ipynb
14
13
  .ipynb_checkpoints/
15
14
  .coverage
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.2
1
+ Metadata-Version: 2.4
2
2
  Name: ceci
3
- Version: 2.2
3
+ Version: 2.3
4
4
  Summary: Lightweight pipeline engine for LSST DESC
5
5
  Author: Joe Zuntz
6
6
  License: BSD 3-Clause License
@@ -15,14 +15,14 @@ Classifier: Programming Language :: Python :: 3.10
15
15
  Description-Content-Type: text/markdown
16
16
  License-File: LICENSE
17
17
  Requires-Dist: pyyaml>=5.1
18
+ Requires-Dist: ruamel.yaml
18
19
  Requires-Dist: psutil
19
20
  Requires-Dist: graphlib_backport; python_version < "3.9"
21
+ Requires-Dist: networkx
22
+ Requires-Dist: jinja2
20
23
  Provides-Extra: parsl
21
24
  Requires-Dist: parsl>=1.0.0; extra == "parsl"
22
25
  Requires-Dist: flask; extra == "parsl"
23
- Provides-Extra: cwl
24
- Requires-Dist: cwlref-runner; extra == "cwl"
25
- Requires-Dist: cwl-utils; extra == "cwl"
26
26
  Provides-Extra: viz
27
27
  Requires-Dist: pygraphviz; extra == "viz"
28
28
  Provides-Extra: dask
@@ -38,8 +38,6 @@ Requires-Dist: dask[distributed]; extra == "test"
38
38
  Provides-Extra: all
39
39
  Requires-Dist: parsl>=1.0.0; extra == "all"
40
40
  Requires-Dist: flask; extra == "all"
41
- Requires-Dist: cwlref-runner; extra == "all"
42
- Requires-Dist: cwl-utils; extra == "all"
43
41
  Requires-Dist: pygraphviz; extra == "all"
44
42
  Requires-Dist: pytest; extra == "all"
45
43
  Requires-Dist: pytest-cov; extra == "all"
@@ -48,6 +46,8 @@ Requires-Dist: mockmpi; extra == "all"
48
46
  Requires-Dist: h5py; extra == "all"
49
47
  Requires-Dist: dask[distributed]>=2023.5.0; extra == "all"
50
48
  Requires-Dist: dask_mpi>=2022.4.0; extra == "all"
49
+ Requires-Dist: jinja2; extra == "all"
50
+ Dynamic: license-file
51
51
 
52
52
  ![Ceci Logo](ceci.png)
53
53
 
@@ -0,0 +1,18 @@
1
+ """Ceci n'est pas une pipeline"""
2
+
3
+ from .stage import PipelineStage
4
+ from .pipeline import (
5
+ Pipeline,
6
+ MiniPipeline,
7
+ ParslPipeline,
8
+ DryRunPipeline,
9
+ FlowChartPipeline,
10
+ )
11
+ from .main import run_pipeline
12
+ import importlib.metadata
13
+
14
+ try:
15
+ __version__ = importlib.metadata.metadata(__name__)["Version"]
16
+ except: # pragma: no cover
17
+ # package is not installed
18
+ pass
@@ -0,0 +1,34 @@
1
+ # file generated by setuptools-scm
2
+ # don't change, don't track in version control
3
+
4
+ __all__ = [
5
+ "__version__",
6
+ "__version_tuple__",
7
+ "version",
8
+ "version_tuple",
9
+ "__commit_id__",
10
+ "commit_id",
11
+ ]
12
+
13
+ TYPE_CHECKING = False
14
+ if TYPE_CHECKING:
15
+ from typing import Tuple
16
+ from typing import Union
17
+
18
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
19
+ COMMIT_ID = Union[str, None]
20
+ else:
21
+ VERSION_TUPLE = object
22
+ COMMIT_ID = object
23
+
24
+ version: str
25
+ __version__: str
26
+ __version_tuple__: VERSION_TUPLE
27
+ version_tuple: VERSION_TUPLE
28
+ commit_id: COMMIT_ID
29
+ __commit_id__: COMMIT_ID
30
+
31
+ __version__ = version = '2.3'
32
+ __version_tuple__ = version_tuple = (2, 3)
33
+
34
+ __commit_id__ = commit_id = 'ged188fcdb'
@@ -3,12 +3,8 @@
3
3
  import os
4
4
  import sys
5
5
  import argparse
6
- import subprocess
7
6
  from .pipeline import Pipeline
8
- from .sites import load, set_default_site, get_default_site
9
- from .utils import extra_paths
10
7
  from ._version import __version__
11
- import contextlib
12
8
 
13
9
  # Add the current dir to the path - often very useful
14
10
  sys.path.append(os.getcwd())
@@ -33,7 +29,15 @@ parser.add_argument(
33
29
  parser.add_argument(
34
30
  "extra_config",
35
31
  nargs="*",
36
- help="Over-ride the main pipeline yaml file e.g. launcher.name=cwl",
32
+ help="Over-ride the main pipeline yaml file e.g. launcher.name=parsl",
33
+ )
34
+
35
+ parser.add_argument(
36
+ "-t",
37
+ "--template-parameters",
38
+ nargs="*",
39
+ help="Template parameters to use in the pipeline configuration file, e.g. -t some_directory=xyz field=north",
40
+ default="",
37
41
  )
38
42
 
39
43
  parser.add_argument(
@@ -59,54 +63,11 @@ def run_pipeline(pipe_config):
59
63
  status : `int`
60
64
  Usual unix convention of 0 -> success, non-zero is an error code
61
65
  """
62
- with prepare_for_pipeline(pipe_config):
63
- p = Pipeline.create(pipe_config)
64
- status = p.run()
66
+ p = Pipeline.create(pipe_config)
67
+ status = p.run()
65
68
  return status
66
69
 
67
70
 
68
- @contextlib.contextmanager
69
- def prepare_for_pipeline(pipe_config):
70
- """
71
- Prepare the paths and launcher needed to read and run a pipeline.
72
- """
73
-
74
- # Later we will add these paths to sys.path for running here,
75
- # but we will also need to pass them to the sites below so that
76
- # they can be added within any containers or other launchers
77
- # that we use
78
- paths = pipe_config.get("python_paths", [])
79
- if isinstance(paths, str): # pragma: no cover
80
- paths = paths.split()
81
-
82
- # Get information (maybe the default) on the launcher we may be using
83
- launcher_config = pipe_config.setdefault("launcher", {"name": "mini"})
84
- site_config = pipe_config.get("site", {"name": "local"})
85
-
86
- # Pass the paths along to the site
87
- site_config["python_paths"] = paths
88
- load(launcher_config, [site_config])
89
-
90
- # Python modules in which to search for pipeline stages
91
- modules = pipe_config.get("modules", "").split()
92
-
93
- # This helps with testing
94
- default_site = get_default_site()
95
-
96
- # temporarily add the paths to sys.path,
97
- # but remove them at the end
98
- with extra_paths(paths):
99
-
100
- # Import modules. We have to do this because the definitions
101
- # of the stages can be inside.
102
- for module in modules:
103
- __import__(module)
104
-
105
- try:
106
- yield
107
- finally:
108
- set_default_site(default_site)
109
-
110
71
 
111
72
  def main(): # pragma: no cover
112
73
  """Main function called when ceci is invoked on the command line"""
@@ -116,7 +77,8 @@ def main(): # pragma: no cover
116
77
  if args.flow_chart:
117
78
  args.dry_run = True
118
79
  pipe_config = Pipeline.build_config(
119
- args.pipeline_config, args.extra_config, args.dry_run, args.flow_chart
80
+ args.pipeline_config, args.extra_config, args.dry_run, args.flow_chart,
81
+ args.template_parameters
120
82
  )
121
83
  status = run_pipeline(pipe_config)
122
84
 
@@ -377,7 +377,7 @@ class Runner:
377
377
  # status==0 indicates sucess in job, so free resources
378
378
  else:
379
379
  run_time = make_run_time_string(default_timer() - start_time)
380
- print(f"Job {job.name} has completed successfully in {run_time} seconds !")
380
+ print(f"Job {job.name} has completed successfully in {run_time}")
381
381
  # Call back with info about the successful job and how it ran
382
382
  self.callback(
383
383
  EVENT_COMPLETED,
@@ -0,0 +1,15 @@
1
+ """Module with core pipeline functionality"""
2
+
3
+ from .pipeline import (
4
+ Pipeline,
5
+ override_config,
6
+ RESUME_MODE_RESUME,
7
+ RESUME_MODE_RESTART,
8
+ RESUME_MODE_REFUSE,
9
+ )
10
+ from .parsl import ParslPipeline
11
+ from .dry_run import DryRunPipeline
12
+ from .flow_chart import FlowChartPipeline
13
+ from .mini import MiniPipeline
14
+ from .file_manager import FileManager
15
+ from .sec import StageExecutionConfig
@@ -0,0 +1,43 @@
1
+ import sys
2
+ from .pipeline import Pipeline
3
+ from ..utils import embolden
4
+
5
+
6
+ class DryRunPipeline(Pipeline):
7
+ """A pipeline subclass which just does a dry-run, showing which commands
8
+ would be executed.
9
+
10
+ See the base class for almost all behaviour.
11
+
12
+ No additional attributes.
13
+ """
14
+
15
+ def initiate_run(self, overall_inputs):
16
+ return []
17
+
18
+ def should_skip_stage(self, stage):
19
+ return False
20
+
21
+ def enqueue_job(self, stage, pipeline_files):
22
+ outputs = stage.find_outputs(self.run_config["output_dir"])
23
+ sec = self.stage_execution_config[stage.instance_name]
24
+
25
+ cmd = sec.generate_full_command(pipeline_files, outputs, self.stages_config)
26
+
27
+ # Replace the first instance of the stage name with bold
28
+ # text, but only if we are printing to screen. This helps the
29
+ # eye pick out the stage you want to run.
30
+ if sys.stdout.isatty():
31
+ cmd = cmd.replace(stage.instance_name, embolden(stage.instance_name), 1)
32
+
33
+ self.run_info.append(cmd)
34
+ return outputs
35
+
36
+ def run_jobs(self):
37
+ for cmd in self.run_info:
38
+ print(cmd)
39
+ print("\n")
40
+ return 0
41
+
42
+ def find_all_outputs(self):
43
+ return {}
@@ -0,0 +1,73 @@
1
+ class FileManager(dict):
2
+ """Small class to manage files within a particular Pipeline
3
+
4
+ This is a dict which is used for tag to path mapping,
5
+ but has a couple of additional dicts to manage the reverse mapping and the
6
+ tag to type mapping.
7
+
8
+
9
+ The tag to path mapping is the thing that the Pipeline uses to set the
10
+ input paths for downstream stages that use the outputs of earlier stages,
11
+ i.e., everything in the pipeline can refer to a particular file by tag.
12
+
13
+ The tag defaults to the input or output tag as define in the stage class attributes.
14
+ However, in the case that we want multiple stages of the same class in a pipeline we
15
+ have to alias the tags so that each stage can write to its own location (and so that
16
+ downstream stages can pick up that location correctly)
17
+ """
18
+
19
+ def __init__(self):
20
+ """Constructor, makes empty dictionaries"""
21
+ self._tag_to_type = {}
22
+ self._path_to_tag = {}
23
+ dict.__init__(self)
24
+
25
+ def __setitem__(self, tag, path):
26
+ """Override dict.__setitem__() to also insert the reverse mapping"""
27
+ dict.__setitem__(self, tag, path)
28
+ self._path_to_tag[path] = tag
29
+
30
+ def insert(self, tag, path=None, ftype=None):
31
+ """Insert a file, including the path and the file type
32
+
33
+ Parameters
34
+ ----------
35
+ tag : str
36
+ The tag by which this file will be identified
37
+ path : str
38
+ The path to this file
39
+ ftype : type
40
+ The file type for this file
41
+ """
42
+ if path is not None:
43
+ self[tag] = path
44
+ self._path_to_tag[path] = tag
45
+ if tag not in self._tag_to_type:
46
+ self._tag_to_type[tag] = ftype
47
+
48
+ def get_type(self, tag):
49
+ """Return the file type associated to a given tag"""
50
+ return self._tag_to_type[tag]
51
+
52
+ def get_path(self, tag):
53
+ """Return the path associated to a give tag"""
54
+ return self[tag]
55
+
56
+ def get_tag(self, path):
57
+ """Return the tag associated to a given path"""
58
+ return self._path_to_tag[path]
59
+
60
+ def insert_paths(self, path_dict):
61
+ """Insert a set of paths from a dict that has tag, path pairs"""
62
+ for key, val in path_dict.items():
63
+ self.insert(key, path=val)
64
+
65
+ def insert_outputs(self, stage, outdir):
66
+ """Insert a set of tags and associated paths and file types that are output by a stage"""
67
+ stage_outputs = stage.find_outputs(outdir)
68
+ for tag, ftype in stage.outputs:
69
+ aliased_tag = stage.get_aliased_tag(tag)
70
+ path = stage_outputs[aliased_tag]
71
+ self.insert(aliased_tag, path=path, ftype=ftype)
72
+ return stage_outputs
73
+
@@ -0,0 +1,8 @@
1
+ from .dry_run import DryRunPipeline
2
+
3
+ class FlowChartPipeline(DryRunPipeline):
4
+ def run_jobs(self):
5
+ filename = self.run_config["flow_chart"]
6
+ self.make_flow_chart(filename)
7
+ return 0
8
+
@@ -0,0 +1,199 @@
1
+ import networkx
2
+ import collections
3
+
4
+ def build_graph(stage_names, stage_classes, stage_aliases, overall_inputs):
5
+ """
6
+ Build a directed graph of the pipeline stages and their inputs and outputs.
7
+
8
+ This also checks for various pipeline problems like missing inputs,
9
+ multiple stages generating the same output, and cyclic dependencies.
10
+
11
+ Parameters
12
+ ----------
13
+ stage_names : `list` of `str`
14
+ The names of the stages in the pipeline
15
+ stage_classes : `list` of `PipelineStage` subclasses
16
+ The classes of the stages in the pipeline
17
+ stage_aliases : `list` of `dict`
18
+ A list of dictionaries mapping tags to aliases for each stage
19
+ overall_inputs : `dict` of `str: str`
20
+ A list of overall input files that are not generated by any stage but are needed by the pipeline
21
+
22
+ Returns
23
+ -------
24
+ graph : `networkx.DiGraph`
25
+ A directed graph representing the pipeline stages, their inputs, and outputs
26
+ """
27
+
28
+ # Build a graph with all the stages and files in it
29
+ graph = networkx.DiGraph()
30
+
31
+ # First add all the overall inputs
32
+ for inp in overall_inputs:
33
+ graph.add_node(inp, type="input")
34
+
35
+ # Now go through all the stages. We do this in two passes,
36
+ # one to add the stages themselves and their outputs, and one
37
+ # to connect in all their inputs
38
+ for stage_name, stage_class, aliases in zip(stage_names, stage_classes, stage_aliases):
39
+ if stage_name in graph.nodes:
40
+ raise ValueError(f"Stage {stage_name} is used multiple times in the pipeline")
41
+ graph.add_node(stage_name, type="stage", stage_class=stage_class)
42
+
43
+ # Now set up all the outputs of the stage, first checking to see if it has been aliased
44
+ for tag1 in stage_class.output_tags():
45
+ tag = aliases.get(tag1, tag1)
46
+ if tag in overall_inputs:
47
+ raise ValueError(f"Tag {tag} is generated by pipeline stage {stage_name} but is also listed as an overall input to the pipeline")
48
+ graph.add_node(tag, type="output", creator=stage_class, original_tag=tag1)
49
+ graph.add_edge(stage_name, tag)
50
+
51
+ # Now check for outputs that are generated by multiple stages
52
+ for node in graph.nodes:
53
+ if graph.nodes[node]["type"] == "output":
54
+ parents = list(graph.predecessors(node))
55
+ if len(parents) > 1:
56
+ raise ValueError(f"Output {node} is generated by multiple stages: {parents}")
57
+
58
+
59
+ # Now go through and add in all the inputs needed for each stage:
60
+ missing = collections.defaultdict(list)
61
+ for stage_name, stage_class, aliases in zip(stage_names, stage_classes, stage_aliases):
62
+ for tag in stage_class.input_tags():
63
+ tag = aliases.get(tag, tag)
64
+ if tag not in graph.nodes:
65
+ missing[tag].append(stage_name)
66
+ else:
67
+ graph.add_edge(tag, stage_name)
68
+
69
+ if missing:
70
+ msg = []
71
+ for tag, stages in missing.items():
72
+ stages = ", ".join(stages)
73
+ msg.append(f"- {tag} - needed by stages {stages}")
74
+ msg = "\n.join(msg)"
75
+ raise ValueError(f"The following files are needed by the pipeline but not generated by any stage:\n{msg}")
76
+
77
+ # Now check for cycles in the graph
78
+ cycles = list(networkx.simple_cycles(graph))
79
+ if cycles:
80
+ msg = []
81
+ for cycle in cycles:
82
+ cycle_text = " -> ".join(cycle)
83
+ cycle_text += " -> " + cycle[0]
84
+ msg.append(f"- {cycle_text}")
85
+ raise ValueError(f"Your pipeline is cyclic. The following cycles were found:\n{msg}")
86
+
87
+ return graph
88
+
89
+ def get_static_ordering(graph):
90
+ # get a topological ordering of the graph
91
+ # this will be a list of all the nodes in the graph, including the input and output files
92
+ ordering = list(networkx.topological_sort(graph))
93
+
94
+ # cut down to just the stages
95
+ ordering = [node for node in ordering if graph.nodes[node]["type"] == "stage"]
96
+
97
+ return ordering
98
+
99
+ def trim_pipeline_graph(graph, from_=None, to_=None):
100
+ """
101
+ Trim the pipeline graph to only include the stages between `from_` and `to_`.
102
+
103
+ Either `from_` or `to_` can be `None`, in which case no trimming is done in that direction.
104
+
105
+ They can be single strings, or lists of strings, in which case the graph is trimmed to include all stages
106
+ between any of the stages in `from_` and any of the stages in `to
107
+
108
+ Parameters
109
+ ----------
110
+ graph : `networkx.DiGraph`
111
+ The original pipeline graph
112
+ from_ : `str`
113
+ The name of the stage(s) or file(s) to start from.
114
+ to_ : `str`
115
+ The name of the stage(s) or file(s) to to end at.
116
+
117
+ Returns
118
+ -------
119
+ trimmed_graph : `networkx.DiGraph`
120
+ The trimmed pipeline graph
121
+ """
122
+ if isinstance(from_, str):
123
+ from_ = [from_]
124
+ if isinstance(to_, str):
125
+ to_ = [to_]
126
+
127
+ # Descendents is all nodes that are descendend from any of the nodes in `from_`
128
+ # if from_ is None then it includes all nodes in the graph
129
+ descendents = set()
130
+
131
+ if from_ is None:
132
+ descendents.update(graph.nodes)
133
+ else:
134
+ for node in from_:
135
+ if node not in graph.nodes:
136
+ raise ValueError(f"Node {node} is not in the pipeline graph")
137
+ # first include the nodes in `from_` themselves
138
+ descendents.add(node)
139
+ descendents.update(networkx.descendants(graph, node))
140
+
141
+ # Ancestors is all nodes that are ancestors of any of the nodes in `to_`
142
+ # if to_ is None then it includes all nodes in the graph
143
+ ancestors = set()
144
+ if to_ is None:
145
+ ancestors.update(graph.nodes)
146
+ else:
147
+ for node in to_:
148
+ if node not in graph.nodes:
149
+ raise ValueError(f"Node {node} is not in the pipeline graph")
150
+ ancestors.add(node)
151
+ ancestors.update(networkx.ancestors(graph, node))
152
+
153
+ # Now we want to keep everything that is both a descendent of `from_` and an ancestor of `to_`
154
+ # This is the intersection of the two sets
155
+ nodes_to_keep = descendents.intersection(ancestors)
156
+
157
+ # We also want to keep the input and output files for the stages we are keeping
158
+ # (though not the stages that make those)
159
+ more_to_keep = set()
160
+ for node in nodes_to_keep:
161
+ if graph.nodes[node]["type"] == "stage":
162
+ # Get all the outputs of this stage
163
+ outputs = [n for n in graph.successors(node) if graph.nodes[n]["type"] == "output"]
164
+ more_to_keep.update(outputs)
165
+ # same for the inputs to all the stages
166
+ inputs = [n for n in graph.predecessors(node) if graph.nodes[n]["type"] in ["input", "output"]]
167
+ more_to_keep.update(inputs)
168
+ nodes_to_keep.update(more_to_keep)
169
+
170
+ # Create a subgraph with just these nodes
171
+ trimmed_graph = graph.subgraph(nodes_to_keep).copy()
172
+
173
+ # Some stages that were previously outputs of existing stages
174
+ # must now be overall inputs to the pipeline instead because
175
+ # the stage that generated them has been trimmed. We need to keep
176
+ # track of this
177
+ converted_inputs = {}
178
+ for node_name in list(trimmed_graph.nodes):
179
+ node = trimmed_graph.nodes[node_name]
180
+
181
+ if node["type"] == "output":
182
+ # The node will be listed as an output but will have no
183
+ # predecessor creating it now.
184
+ predecessors = list(trimmed_graph.predecessors(node_name))
185
+ if not predecessors:
186
+ creator_stage_class = node["creator"]
187
+ original_tag = node["original_tag"]
188
+ # use the original tag to find the file type object
189
+ for t, typ in creator_stage_class.outputs:
190
+ if t == original_tag:
191
+ ftype = typ
192
+ break
193
+ else:
194
+ raise RuntimeError("This should never happen - please open an issue")
195
+ # the node_name is the aliased_tag, so this file name should be correct
196
+ file_name = ftype.make_name(node_name)
197
+ converted_inputs[node_name] = file_name
198
+
199
+ return trimmed_graph, converted_inputs
@@ -0,0 +1,133 @@
1
+ import sys
2
+ from .. import minirunner
3
+ from .pipeline import Pipeline, get_default_site
4
+
5
+ class MiniPipeline(Pipeline):
6
+ """A pipeline subclass that uses Minirunner, a sub-module
7
+ of ceci, to run.
8
+
9
+ Minirununer is a small tool I wrote suitable for interactive
10
+ jobs on cori. It launches jobs locally (not through a batch system),
11
+ which parsl can also do, but it has a simple and clearer (to me at least)
12
+ understanding of available nodes and cores.
13
+
14
+ """
15
+
16
+ def __init__(self, *args, **kwargs):
17
+ """Create a MiniRunner Pipeline
18
+
19
+ In addition to parent initialization parameters (see the
20
+ Pipeline base class), this subclass can take these optional
21
+ keywords.
22
+
23
+ Parameters
24
+ ----------
25
+ callback: function(event_type: str, event_info: dict)
26
+ A function called when jobs launch, complete, or fail,
27
+ and when the pipeline aborts. Can be used for tracing
28
+ execution. Default=None.
29
+
30
+ sleep: function(t: float)
31
+ A function to replace time.sleep called in the pipeline
32
+ to wait until the next time to check process completion
33
+ Most normal usage will not need this. Default=None.
34
+ """
35
+ self.callback = kwargs.pop("callback", None)
36
+ self.sleep = kwargs.pop("sleep", None)
37
+ super().__init__(*args, **kwargs)
38
+
39
+ def build_dag(self, jobs):
40
+ """Build a directed acyclic graph of a set of stages.
41
+
42
+ The DAG is represented by a list of jobs that each other job
43
+ depends on. If all a job's dependencies are complete
44
+ then it can be run.
45
+
46
+ Fun fact: the word "dag" is also Australian slang for a
47
+ "dung-caked lock of wool around the hindquarters of a sheep".
48
+ and is used as a semi-affectionate insult.
49
+
50
+ Parameters
51
+ ----------
52
+
53
+ stages: list[PipelineStage]
54
+ A list of stages to generate the DAG for
55
+
56
+ """
57
+ depend = {}
58
+
59
+ # for each stage in our pipeline ...
60
+ for stage in self.stages[:]:
61
+ if stage.instance_name not in jobs:
62
+ continue
63
+ job = jobs[stage.instance_name]
64
+ depend[job] = []
65
+ # check for each of the inputs for that stage ...
66
+ for tag in stage.input_tags():
67
+ aliased_tag = stage.get_aliased_tag(tag)
68
+ for potential_parent in self.stages[:]:
69
+ # if that stage is supplied by another pipeline stage
70
+ if potential_parent.instance_name not in jobs: # pragma: no cover
71
+ continue
72
+ potential_parent_tags = [
73
+ potential_parent.get_aliased_tag(tag_)
74
+ for tag_ in potential_parent.output_tags()
75
+ ]
76
+ if aliased_tag in potential_parent_tags:
77
+ depend[job].append(jobs[potential_parent.instance_name])
78
+ return depend
79
+
80
+ def initiate_run(self, overall_inputs):
81
+ jobs = {}
82
+ stages = []
83
+ return jobs, stages
84
+
85
+ def enqueue_job(self, stage, pipeline_files):
86
+ sec = self.stage_execution_config[stage.instance_name]
87
+ outputs = stage.find_outputs(self.run_config["output_dir"])
88
+ cmd = sec.generate_full_command(pipeline_files, outputs, self.stages_config)
89
+ job = minirunner.Job(
90
+ stage.instance_name,
91
+ cmd,
92
+ cores=sec.threads_per_process * sec.nprocess,
93
+ nodes=sec.nodes,
94
+ )
95
+ self.run_info[0][stage.instance_name] = job
96
+ self.run_info[1].append(stage)
97
+ return outputs
98
+
99
+ def run_jobs(self):
100
+ jobs, _ = self.run_info
101
+ # Now the jobs have all been queued, build them into a graph
102
+ graph = self.build_dag(jobs)
103
+ nodes = get_default_site().info["nodes"]
104
+ log_dir = self.run_config["log_dir"]
105
+
106
+ # This pipeline, being mainly for testing, can only
107
+ # run at a single site, so we can assume here that the
108
+ # sites are all the same
109
+ sec = self.stage_execution_config[self.stage_names[0]]
110
+ nodes = sec.site.info["nodes"]
111
+
112
+ # Run under minirununer
113
+ runner = minirunner.Runner(
114
+ nodes, graph, log_dir, callback=self.callback, sleep=self.sleep
115
+ )
116
+ interval = self.launcher_config.get("interval", 3)
117
+ try:
118
+ runner.run(interval)
119
+ except minirunner.FailedJob as error:
120
+ sys.stderr.write(
121
+ f"""
122
+ *************************************************
123
+ Error running pipeline stage {error.job_name}.
124
+ Failed after {error.run_time}.
125
+
126
+ Standard output and error streams in {log_dir}/{error.job_name}.out
127
+ *************************************************
128
+ """
129
+ )
130
+ return 1
131
+
132
+ return 0
133
+