ceci 2.0__tar.gz → 2.1__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 (85) hide show
  1. {ceci-2.0 → ceci-2.1}/PKG-INFO +2 -2
  2. {ceci-2.0 → ceci-2.1}/ceci/_version.py +2 -2
  3. {ceci-2.0 → ceci-2.1}/ceci/minirunner.py +25 -7
  4. {ceci-2.0 → ceci-2.1}/ceci/pipeline.py +79 -15
  5. {ceci-2.0 → ceci-2.1}/ceci/stage.py +261 -29
  6. {ceci-2.0 → ceci-2.1}/ceci/update_pipelines_for_ceci_2.py +7 -2
  7. {ceci-2.0 → ceci-2.1}/ceci/utils.py +8 -0
  8. {ceci-2.0 → ceci-2.1}/ceci.egg-info/PKG-INFO +2 -2
  9. {ceci-2.0 → ceci-2.1}/tests/test_interactive.py +0 -1
  10. {ceci-2.0 → ceci-2.1}/tests/test_pipeline.py +19 -1
  11. {ceci-2.0 → ceci-2.1}/tests/test_stage.py +77 -3
  12. {ceci-2.0 → ceci-2.1}/.coveragerc +0 -0
  13. {ceci-2.0 → ceci-2.1}/.github/workflows/ci.yml +0 -0
  14. {ceci-2.0 → ceci-2.1}/.github/workflows/publish.yml +0 -0
  15. {ceci-2.0 → ceci-2.1}/.gitignore +0 -0
  16. {ceci-2.0 → ceci-2.1}/.pylintrc +0 -0
  17. {ceci-2.0 → ceci-2.1}/LICENSE +0 -0
  18. {ceci-2.0 → ceci-2.1}/README.md +0 -0
  19. {ceci-2.0 → ceci-2.1}/ceci/__init__.py +0 -0
  20. {ceci-2.0 → ceci-2.1}/ceci/__main__.py +0 -0
  21. {ceci-2.0 → ceci-2.1}/ceci/config.py +0 -0
  22. {ceci-2.0 → ceci-2.1}/ceci/errors.py +0 -0
  23. {ceci-2.0 → ceci-2.1}/ceci/main.py +0 -0
  24. {ceci-2.0 → ceci-2.1}/ceci/monitor.py +0 -0
  25. {ceci-2.0 → ceci-2.1}/ceci/sites/__init__.py +0 -0
  26. {ceci-2.0 → ceci-2.1}/ceci/sites/ccin2p3.py +0 -0
  27. {ceci-2.0 → ceci-2.1}/ceci/sites/local.py +0 -0
  28. {ceci-2.0 → ceci-2.1}/ceci/sites/nersc.py +0 -0
  29. {ceci-2.0 → ceci-2.1}/ceci/sites/site.py +0 -0
  30. {ceci-2.0 → ceci-2.1}/ceci/tools/__init__.py +0 -0
  31. {ceci-2.0 → ceci-2.1}/ceci/tools/ancestors.py +0 -0
  32. {ceci-2.0 → ceci-2.1}/ceci.egg-info/SOURCES.txt +0 -0
  33. {ceci-2.0 → ceci-2.1}/ceci.egg-info/dependency_links.txt +0 -0
  34. {ceci-2.0 → ceci-2.1}/ceci.egg-info/entry_points.txt +0 -0
  35. {ceci-2.0 → ceci-2.1}/ceci.egg-info/requires.txt +0 -0
  36. {ceci-2.0 → ceci-2.1}/ceci.egg-info/top_level.txt +0 -0
  37. {ceci-2.0 → ceci-2.1}/ceci.png +0 -0
  38. {ceci-2.0 → ceci-2.1}/ceci_example/__init__.py +0 -0
  39. {ceci-2.0 → ceci-2.1}/ceci_example/__main__.py +0 -0
  40. {ceci-2.0 → ceci-2.1}/ceci_example/example_stages.py +0 -0
  41. {ceci-2.0 → ceci-2.1}/ceci_example/types.py +0 -0
  42. {ceci-2.0 → ceci-2.1}/ceci_example_namespace/example_stages.py +0 -0
  43. {ceci-2.0 → ceci-2.1}/do_cover.sh +0 -0
  44. {ceci-2.0 → ceci-2.1}/docs/Makefile +0 -0
  45. {ceci-2.0 → ceci-2.1}/docs/conf.py +0 -0
  46. {ceci-2.0 → ceci-2.1}/docs/config.rst +0 -0
  47. {ceci-2.0 → ceci-2.1}/docs/config2.rst +0 -0
  48. {ceci-2.0 → ceci-2.1}/docs/index.rst +0 -0
  49. {ceci-2.0 → ceci-2.1}/docs/installation.rst +0 -0
  50. {ceci-2.0 → ceci-2.1}/docs/launchers.rst +0 -0
  51. {ceci-2.0 → ceci-2.1}/docs/logo.png +0 -0
  52. {ceci-2.0 → ceci-2.1}/docs/overview.rst +0 -0
  53. {ceci-2.0 → ceci-2.1}/docs/sites.rst +0 -0
  54. {ceci-2.0 → ceci-2.1}/docs/stages.rst +0 -0
  55. {ceci-2.0 → ceci-2.1}/docs/tutorial.rst +0 -0
  56. {ceci-2.0 → ceci-2.1}/nb/ceci_interactive_example.ipynb +0 -0
  57. {ceci-2.0 → ceci-2.1}/pyproject.toml +0 -0
  58. {ceci-2.0 → ceci-2.1}/setup.cfg +0 -0
  59. {ceci-2.0 → ceci-2.1}/setup.py +0 -0
  60. {ceci-2.0 → ceci-2.1}/tests/__init__.py +0 -0
  61. {ceci-2.0 → ceci-2.1}/tests/config.yml +0 -0
  62. {ceci-2.0 → ceci-2.1}/tests/config_nm.yml +0 -0
  63. {ceci-2.0 → ceci-2.1}/tests/create_registry_entries.py +0 -0
  64. {ceci-2.0 → ceci-2.1}/tests/inputs/dm.txt +0 -0
  65. {ceci-2.0 → ceci-2.1}/tests/inputs/fiducial_cosmology.txt +0 -0
  66. {ceci-2.0 → ceci-2.1}/tests/test.hdf5 +0 -0
  67. {ceci-2.0 → ceci-2.1}/tests/test.yml +0 -0
  68. {ceci-2.0 → ceci-2.1}/tests/test_cmd.py +0 -0
  69. {ceci-2.0 → ceci-2.1}/tests/test_dask.py +0 -0
  70. {ceci-2.0 → ceci-2.1}/tests/test_dataregistry.yml +0 -0
  71. {ceci-2.0 → ceci-2.1}/tests/test_executables.py +0 -0
  72. {ceci-2.0 → ceci-2.1}/tests/test_flow_chart.py +0 -0
  73. {ceci-2.0 → ceci-2.1}/tests/test_helpers.py +0 -0
  74. {ceci-2.0 → ceci-2.1}/tests/test_main.py +0 -0
  75. {ceci-2.0 → ceci-2.1}/tests/test_minirunner.py +0 -0
  76. {ceci-2.0 → ceci-2.1}/tests/test_monitor.py +0 -0
  77. {ceci-2.0 → ceci-2.1}/tests/test_namespace.yml +0 -0
  78. {ceci-2.0 → ceci-2.1}/tests/test_python_paths.py +0 -0
  79. {ceci-2.0 → ceci-2.1}/tests/test_site.py +0 -0
  80. {ceci-2.0 → ceci-2.1}/tests/test_snapshot.py +0 -0
  81. {ceci-2.0 → ceci-2.1}/tests/test_update_script.py +0 -0
  82. {ceci-2.0 → ceci-2.1}/tests/test_utils.py +0 -0
  83. {ceci-2.0 → ceci-2.1}/tests/update_test_files/config.yml +0 -0
  84. {ceci-2.0 → ceci-2.1}/tests/update_test_files/pipeline1.yml +0 -0
  85. {ceci-2.0 → ceci-2.1}/tests/update_test_files/pipeline2.yml +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.2
2
2
  Name: ceci
3
- Version: 2.0
3
+ Version: 2.1
4
4
  Summary: Lightweight pipeline engine for LSST DESC
5
5
  Author: Joe Zuntz
6
6
  License: BSD 3-Clause License
@@ -12,5 +12,5 @@ __version__: str
12
12
  __version_tuple__: VERSION_TUPLE
13
13
  version_tuple: VERSION_TUPLE
14
14
 
15
- __version__ = version = '2.0'
16
- __version_tuple__ = version_tuple = (2, 0)
15
+ __version__ = version = '2.1'
16
+ __version_tuple__ = version_tuple = (2, 1)
@@ -36,9 +36,10 @@ class TimeOut(RunnerError):
36
36
  class FailedJob(RunnerError):
37
37
  """Error for when a job has failed."""
38
38
 
39
- def __init__(self, msg, job_name):
39
+ def __init__(self, msg, job_name, run_time):
40
40
  super().__init__(msg)
41
41
  self.job_name = job_name
42
+ self.run_time = run_time
42
43
 
43
44
 
44
45
  class Node:
@@ -145,6 +146,21 @@ def null_callback(
145
146
  ): # pylint: disable=unused-argument,missing-function-docstring
146
147
  pass
147
148
 
149
+ def make_run_time_string(seconds):
150
+ """Convert the time in seconds to a string of the form
151
+ "X days, Y hours, Z minutes, W seconds"
152
+ """
153
+ if seconds < 60:
154
+ return f"{seconds:.1f} seconds"
155
+ minutes, seconds = divmod(seconds, 60)
156
+ if minutes < 60:
157
+ return f"{minutes:.0f} minutes, {seconds:.1f} seconds"
158
+ hours, minutes = divmod(minutes, 60)
159
+ if hours < 24:
160
+ return f"{hours:.0f} hours, {minutes:.0f} minutes, {seconds:.1f} seconds"
161
+ days, hours = divmod(hours, 24)
162
+ return f"{days:.0f} days, {hours:.0f} hours, {minutes:.0f} minutes, {seconds:.1f} seconds"
163
+
148
164
 
149
165
  class Runner:
150
166
  """The main pipeline runner class.
@@ -245,7 +261,7 @@ class Runner:
245
261
 
246
262
  def abort(self):
247
263
  """End the pipeline and kill all running jobs."""
248
- for process, _, _ in self.running:
264
+ for process, _, _, _ in self.running:
249
265
  process.kill()
250
266
 
251
267
  for node in self.nodes:
@@ -275,7 +291,7 @@ class Runner:
275
291
  p = subprocess.Popen(
276
292
  cmd, shell=True, stdout=stdout, stderr=subprocess.STDOUT
277
293
  ) # pylint: disable=consider-using-with
278
- self.running.append((p, job, alloc))
294
+ self.running.append((p, job, alloc, default_timer()))
279
295
  self.callback(
280
296
  EVENT_LAUNCH,
281
297
  {"job": job, "stdout": stdout_file, "process": p, "nodes": alloc},
@@ -337,12 +353,12 @@ class Runner:
337
353
  completed_jobs = []
338
354
  continuing_jobs = []
339
355
  # loop through all known running ones
340
- for process, job, alloc in self.running:
356
+ for process, job, alloc, start_time in self.running:
341
357
  # check status
342
358
  status = process.poll()
343
359
  # None indicates job is still running
344
360
  if status is None: # pragma: no cover
345
- continuing_jobs.append((process, job, alloc))
361
+ continuing_jobs.append((process, job, alloc, start_time))
346
362
  # status !=0 indicates error in job.
347
363
  # kill everything
348
364
  elif status:
@@ -355,11 +371,13 @@ class Runner:
355
371
  EVENT_FAIL,
356
372
  {"job": job, "status": status, "process": process, "nodes": alloc},
357
373
  )
374
+ fail_time = make_run_time_string(default_timer() - start_time)
358
375
  self.abort()
359
- raise FailedJob(job.cmd, job.name)
376
+ raise FailedJob(job.cmd, job.name, fail_time)
360
377
  # status==0 indicates sucess in job, so free resources
361
378
  else:
362
- print(f"Job {job.name} has completed successfully!")
379
+ run_time = make_run_time_string(default_timer() - start_time)
380
+ print(f"Job {job.name} has completed successfully in {run_time} seconds !")
363
381
  # Call back with info about the successful job and how it ran
364
382
  self.callback(
365
383
  EVENT_COMPLETED,
@@ -6,13 +6,18 @@ import graphlib
6
6
  import yaml
7
7
  import shutil
8
8
  from abc import abstractmethod
9
- import warnings
9
+ import collections
10
10
 
11
11
  from .stage import PipelineStage
12
12
  from . import minirunner
13
13
  from .sites import load, get_default_site
14
14
  from .utils import embolden, extra_paths
15
15
 
16
+ RESUME_MODE_RESUME = "resume"
17
+ RESUME_MODE_RESTART = "restart"
18
+ RESUME_MODE_REFUSE = "refuse"
19
+
20
+
16
21
 
17
22
  def override_config(config, extra):
18
23
  """Override configuration parameters with extra parameters provided on command line
@@ -185,16 +190,7 @@ class StageExecutionConfig:
185
190
  self.stage_class = PipelineStage.get_stage(
186
191
  self.class_name, self.module_name
187
192
  )
188
- # EAC. Ideally we would just pass the aliases into the c'tor of self.stage_class(),
189
- # but that would requiring changing the signature of every sub-class, so we do this
190
- # instead. At some point we might want to migrate to doing it the better way
191
- try:
192
- self.stage_obj = self.stage_class(args, aliases=self.aliases)
193
- except TypeError:
194
- self.stage_obj = self.stage_class(args)
195
- self.stage_obj._aliases.update(**self.aliases)
196
- self.stage_obj._io_checked = False
197
- self.stage_obj.check_io()
193
+ self.stage_obj = self.stage_class(args, aliases=self.aliases)
198
194
  return self.stage_obj
199
195
 
200
196
  def generate_full_command(self, inputs, outputs, config):
@@ -373,11 +369,16 @@ class Pipeline:
373
369
  run_config = {
374
370
  "output_dir": pipe_config.get("output_dir", "."),
375
371
  "log_dir": pipe_config.get("log_dir", "."),
376
- "resume": pipe_config.get("resume", False),
372
+ "resume": pipe_config.get("resume", RESUME_MODE_RESUME),
377
373
  "flow_chart": pipe_config.get("flow_chart", ""),
378
374
  "registry": pipe_config.get("registry", None),
379
375
  }
380
376
 
377
+ if run_config["resume"] is True:
378
+ run_config["resume"] = RESUME_MODE_RESUME
379
+ elif run_config["resume"] is False:
380
+ run_config["resume"] = RESUME_MODE_RESTART
381
+
381
382
  launcher_dict = dict(cwl=CWLPipeline, parsl=ParslPipeline, mini=MiniPipeline)
382
383
 
383
384
  if pipe_config.get("flow_chart", False):
@@ -674,9 +675,11 @@ class Pipeline:
674
675
  `Pipeline.pipeline_files` data member, so that they are available to later stages
675
676
  """
676
677
  kwcopy = kwargs.copy()
678
+ aliases = kwcopy.pop("aliases", {})
679
+ comm = kwcopy.pop("comm", None)
677
680
  kwcopy.update(**self.pipeline_files)
678
- aliases = kwcopy.pop("aliases", None)
679
- stage = stage_class(kwcopy, aliases=aliases)
681
+
682
+ stage = stage_class(kwcopy, comm=comm, aliases=aliases)
680
683
  return self.add_stage(stage)
681
684
 
682
685
  def remove_stage(self, name):
@@ -827,7 +830,9 @@ class Pipeline:
827
830
  # In interactive use the stage object might already be created
828
831
  # otherwise we need to create it
829
832
  if sec.stage_obj is not None:
833
+ orig_stage_config = stages_config_data.get(stage_name, {}).copy()
830
834
  stage = sec.stage_obj
835
+ stage.config.update(**orig_stage_config)
831
836
  else:
832
837
  # Find the inputs for this stage and set up the arguments
833
838
  # to the stage init method
@@ -956,7 +961,24 @@ class Pipeline:
956
961
 
957
962
  def should_skip_stage(self, stage):
958
963
  """Return true if we should skip a stage because it is finished and we are in resume mode"""
959
- return stage.should_skip(self.run_config)
964
+ resume_mode = self.run_config["resume"]
965
+ if resume_mode is True:
966
+ resume_mode = "resume"
967
+ elif resume_mode is False:
968
+ resume_mode = "restart"
969
+
970
+ if resume_mode == "restart":
971
+ return False
972
+
973
+ outputs = stage.find_outputs(self.run_config["output_dir"]).values()
974
+
975
+ if resume_mode == "resume":
976
+ return all(os.path.exists(output) for output in outputs)
977
+ elif resume_mode == "refuse":
978
+ if any(os.path.exists(output) for output in outputs):
979
+ raise RuntimeError(f"Output files already exist for stage {stage} and resume mode is 'refuse'")
980
+ else:
981
+ raise ValueError(f"Unknown resume mode: {resume_mode}")
960
982
 
961
983
  def save(self, pipefile, stagefile=None, reduce_config=False, **kwargs):
962
984
  """Save this pipeline state to a yaml file
@@ -1046,6 +1068,10 @@ class Pipeline:
1046
1068
  # Nodes we have already added
1047
1069
  seen = set()
1048
1070
 
1071
+ # Dictionary to track nodes by their inputs and outputs
1072
+ node_groups = {}
1073
+
1074
+
1049
1075
  # Add overall pipeline inputs
1050
1076
  for inp in self.overall_inputs.keys():
1051
1077
  graph.add_node(inp, shape="box", color="gold", style="filled")
@@ -1071,6 +1097,43 @@ class Pipeline:
1071
1097
  seen.add(out)
1072
1098
  graph.add_edge(stage.instance_name, out, color="black")
1073
1099
 
1100
+ # We want to group together all the files that all created
1101
+ # by the same stage and also all used by the same stages, to
1102
+ # reduce the number of nodes in the graph and make it more readable.
1103
+ # First we find that grouping.
1104
+ node_groups = collections.defaultdict(list)
1105
+ for node in graph.nodes_iter():
1106
+ # only affect the nodes representing files
1107
+ if node.attr['color'] != "skyblue":
1108
+ continue
1109
+ # Find the stage node that created this file,
1110
+ # and all the stage nodes that make use of it
1111
+ edge_in = graph.in_edges(node)[0]
1112
+ creator = edge_in[0]
1113
+ users = []
1114
+ for edge in graph.out_edges(node):
1115
+ users.append(edge[1])
1116
+ key = (creator, tuple(users))
1117
+ node_groups[key].append(node)
1118
+
1119
+ # Now we remove all the groups of nodes with more than one in
1120
+ # and replace them with a single node
1121
+ for key, nodes in node_groups.items():
1122
+ if len(nodes) > 1:
1123
+ if len(nodes) > 4:
1124
+ # make a string with two nodes per line
1125
+ node_names = []
1126
+ for i in range(0, len(nodes), 2):
1127
+ node_names.append(", ".join(nodes[i:i+2]))
1128
+ new_node = "\n".join(node_names)
1129
+ else:
1130
+ new_node = "\n".join(nodes)
1131
+ graph.remove_nodes_from(nodes)
1132
+ graph.add_node(new_node, shape="box", color="skyblue", style="filled")
1133
+ graph.add_edge(key[0], new_node, color="black")
1134
+ for user in key[1]:
1135
+ graph.add_edge(new_node, user, color="black")
1136
+
1074
1137
  # finally, output the stage to file
1075
1138
  if filename.endswith(".dot"):
1076
1139
  graph.write(filename)
@@ -1439,6 +1502,7 @@ class MiniPipeline(Pipeline):
1439
1502
  f"""
1440
1503
  *************************************************
1441
1504
  Error running pipeline stage {error.job_name}.
1505
+ Failed after {error.run_time}.
1442
1506
 
1443
1507
  Standard output and error streams in {log_dir}/{error.job_name}.out
1444
1508
  *************************************************
@@ -8,11 +8,14 @@ import shutil
8
8
  import cProfile
9
9
  import pdb
10
10
  import datetime
11
+ import warnings
12
+ import socket
11
13
 
12
14
  from abc import abstractmethod
13
15
  from . import errors
14
16
  from .monitor import MemoryMonitor
15
17
  from .config import StageParameter, StageConfig, cast_to_streamable
18
+ from .utils import activate_tracing
16
19
 
17
20
  SERIAL = "serial"
18
21
  MPI_PARALLEL = "mpi"
@@ -102,6 +105,8 @@ class PipelineStage:
102
105
  if comm is not None:
103
106
  self.setup_mpi(comm)
104
107
 
108
+ self.check_io()
109
+
105
110
 
106
111
  @classmethod
107
112
  def make_stage(cls, **kwargs):
@@ -117,13 +122,7 @@ class PipelineStage:
117
122
  for output_ in cls.outputs: # pylint: disable=no-member
118
123
  outtag = output_[0]
119
124
  aliases[outtag] = f"{outtag}_{name}"
120
- # EAC. Ideally we would just pass the aliases into the construction call
121
- # but that would requiring changing the signature of every sub-class, so we do this
122
- # instead. At some point we might want to migrate to doing it the better way
123
- stage = cls(kwcopy, comm=comm)
124
- stage._aliases.update(**aliases)
125
- stage._io_checked = False
126
- stage.check_io()
125
+ stage = cls(kwcopy, comm=comm, aliases=aliases)
127
126
  return stage
128
127
 
129
128
  def get_aliases(self):
@@ -149,8 +148,18 @@ class PipelineStage:
149
148
 
150
149
  @abstractmethod
151
150
  def run(self): # pragma: no cover
152
- """Run the stage and return the execution status"""
151
+ """Run the stage and return the execution status.
152
+
153
+ Subclasses must implemented this method.
154
+ """
153
155
  raise NotImplementedError("run")
156
+
157
+ def validate(self):
158
+ """Check that the inputs actually have the data needed for execution,
159
+ This is called before the run method. It is an optional stage, meant
160
+ for checking that the input to the stage is actual in the form and
161
+ shape needed before an expensive run is executed."""
162
+ pass
154
163
 
155
164
  def load_configs(self, args):
156
165
  """
@@ -181,7 +190,6 @@ class PipelineStage:
181
190
  error_class = type(error)
182
191
  msg = str(error)
183
192
  raise error_class(f"Error configuring {self.instance_name}: {msg}")
184
- self.check_io(args)
185
193
 
186
194
  def check_io(self, args=None):
187
195
  """
@@ -344,7 +352,8 @@ class PipelineStage:
344
352
  path = pathlib.Path(filename).resolve()
345
353
 
346
354
  # Add a description of the parameters to the end of the docstring
347
- if stage_is_complete:
355
+ # If no config options are specified, omit this.
356
+ if stage_is_complete and cls.config_options:
348
357
  config_text = cls._describe_configuration_text()
349
358
  if cls.__doc__ is None:
350
359
  cls.__doc__ = f"Stage {cls.name}\n\nConfiguration Parameters:\n{config_text}"
@@ -607,6 +616,12 @@ I currently know about these stages:
607
616
  help="Report memory use. Argument gives interval in seconds between reports",
608
617
  )
609
618
 
619
+ parser.add_argument(
620
+ "--trace",
621
+ action="store_true",
622
+ help="Enable sending a signal to the process that prints a trace wherever it is",
623
+ )
624
+
610
625
  # Error message we will return if --mpi used on a non-supported
611
626
  # stage.
612
627
  mpi_err = (
@@ -665,6 +680,21 @@ I currently know about these stages:
665
680
  if args.memmon: # pragma: no cover
666
681
  monitor = MemoryMonitor.start_in_thread(interval=args.memmon)
667
682
 
683
+ if args.trace:
684
+ activate_tracing(stage._rank)
685
+
686
+ # Now we try to see if the validation step has been changed,
687
+ # if it has then we will run the validation step, and raise any errors
688
+ try:
689
+ stage.validate()
690
+ except Exception as error:
691
+ if stage.rank==0:
692
+ print(f"Looks like there is an validation error in: {cls.name}",
693
+ "the input data for this stage did not pass the checks implemented on it.")
694
+ print(error)
695
+ raise
696
+
697
+
668
698
  try:
669
699
  stage.run()
670
700
  except Exception as error: # pragma: no cover
@@ -785,13 +815,24 @@ I currently know about these stages:
785
815
 
786
816
  def is_mpi(self):
787
817
  """
788
- Returns True if the stage is being run under MPI.
818
+ Check if the stage is being run under MPI.
819
+
820
+ Returns
821
+ -------
822
+ bool
823
+ True if the stage is being run under MPI
789
824
  """
790
825
  return self._parallel == MPI_PARALLEL
791
826
 
792
827
  def is_dask(self):
793
828
  """
794
- Returns True if the stage is being run in parallel with Dask.
829
+ Check if the stage is being run in parallel with Dask.
830
+
831
+ Returns
832
+ -------
833
+ bool
834
+ True if the stage is being run under MPI
835
+
795
836
  """
796
837
  return self._parallel == DASK_PARALLEL
797
838
 
@@ -942,6 +983,11 @@ I currently know about these stages:
942
983
  Parallel: bool
943
984
  Whether to split data by rank or just give all procs all data.
944
985
  Default=True
986
+
987
+ Returns
988
+ -------
989
+ start, end: tuple
990
+ The start and end of the range of rows to be read by this process
945
991
  """
946
992
  n_chunks = n_rows // chunk_rows
947
993
  if n_chunks * chunk_rows < n_rows: # pragma: no cover
@@ -963,6 +1009,17 @@ I currently know about these stages:
963
1009
  """
964
1010
  Return the path of an input file with the given tag,
965
1011
  which can be aliased.
1012
+
1013
+ Parameters
1014
+ ----------
1015
+ tag: str
1016
+ Tag as listed in self.outputs
1017
+
1018
+ Returns
1019
+ -------
1020
+ path: str
1021
+ The path to the output file
1022
+
966
1023
  """
967
1024
  tag = self.get_aliased_tag(tag)
968
1025
  return self._inputs[tag]
@@ -975,7 +1032,21 @@ I currently know about these stages:
975
1032
  which can be aliased already.
976
1033
 
977
1034
  If final_name is False then use a temporary name - file will
978
- be moved to its final name at the end
1035
+ be moved to its final name at the end. The temporary name
1036
+ is prefixed with `inprogress_`.
1037
+
1038
+ Parameters
1039
+ ----------
1040
+ tag: str
1041
+ Tag as listed in self.outputs
1042
+
1043
+ final_name: bool
1044
+ Default=False. Whether to save to the final name.
1045
+
1046
+ Returns
1047
+ -------
1048
+ path: str
1049
+ The path to the output file
979
1050
  """
980
1051
 
981
1052
  tag = self.get_aliased_tag(tag)
@@ -998,6 +1069,21 @@ I currently know about these stages:
998
1069
  For specialized file types like FITS or HDF5 it will return
999
1070
  a more specific object - see the types.py file for more info.
1000
1071
 
1072
+ Parameters
1073
+ ----------
1074
+ tag: str
1075
+ Tag as listed in self.inputs
1076
+
1077
+ wrapper: bool
1078
+ Whether to return an underlying file object (False) or a data type instance (True)
1079
+
1080
+ **kwargs: dict
1081
+ Extra arguments to pass to the file class constructor
1082
+
1083
+ Returns
1084
+ -------
1085
+ obj: file or object
1086
+ The opened file or object
1001
1087
  """
1002
1088
  path = self.get_input(tag)
1003
1089
  input_class = self.get_input_type(tag)
@@ -1014,7 +1100,7 @@ I currently know about these stages:
1014
1100
  Find and open an output file with the given tag, in write mode.
1015
1101
 
1016
1102
  If final_name is True then they will be opened using their final
1017
- target output name. Otherwise we will prepend "inprogress_" to their
1103
+ target output name. Otherwise we will prepend `inprogress_` to their
1018
1104
  file name. This means we know that if the final file exists then it
1019
1105
  is completed.
1020
1106
 
@@ -1025,12 +1111,11 @@ I currently know about these stages:
1025
1111
 
1026
1112
  Parameters
1027
1113
  ----------
1028
-
1029
1114
  tag: str
1030
1115
  Tag as listed in self.outputs
1031
1116
 
1032
1117
  wrapper: bool
1033
- Default=False. Whether to return a wrapped file
1118
+ Whether to return an underlying file object (False) or a data type instance (True)
1034
1119
 
1035
1120
  final_name: bool
1036
1121
  Default=False. Whether to save to
@@ -1038,6 +1123,10 @@ I currently know about these stages:
1038
1123
  **kwargs:
1039
1124
  Extra args are passed on to the file's class constructor.
1040
1125
 
1126
+ Returns
1127
+ -------
1128
+ obj: file or object
1129
+ The opened file or object
1041
1130
  """
1042
1131
  path = self.get_output(tag, final_name=final_name)
1043
1132
  output_class = self.get_output_type(tag)
@@ -1082,33 +1171,63 @@ I currently know about these stages:
1082
1171
  @classmethod
1083
1172
  def inputs_(cls):
1084
1173
  """
1085
- Return the dict of inputs
1174
+ Return the dict mapping input tags to file names.
1175
+
1176
+ Returns
1177
+ -------
1178
+ in_dict : dict[str:str]
1086
1179
  """
1087
1180
  return cls.inputs # pylint: disable=no-member
1088
1181
 
1089
1182
  @classmethod
1090
1183
  def outputs_(cls):
1091
1184
  """
1092
- Return the dict of inputs
1185
+ Return the dict mapping output tags to file names.
1186
+
1187
+ Returns
1188
+ -------
1189
+ out_dict : dict[str:str]
1093
1190
  """
1094
1191
  return cls.outputs # pylint: disable=no-member
1095
1192
 
1096
1193
  @classmethod
1097
1194
  def output_tags(cls):
1098
1195
  """
1099
- Return the list of output tags required by this stage
1196
+ Return the list of output tags required by this stage.
1197
+
1198
+ Returns
1199
+ -------
1200
+ out_tags : list[str]
1201
+ The list of output tags
1100
1202
  """
1101
1203
  return [tag for tag, _ in cls.outputs_()]
1102
1204
 
1103
1205
  @classmethod
1104
1206
  def input_tags(cls):
1105
1207
  """
1106
- Return the list of input tags required by this stage
1208
+ Return the list of input tags required by this stage.
1209
+
1210
+ Returns
1211
+ -------
1212
+ in_tags : list[str]
1213
+ The list of input tags
1107
1214
  """
1108
1215
  return [tag for tag, _ in cls.inputs_()]
1109
1216
 
1110
1217
  def get_input_type(self, tag):
1111
- """Return the file type class of an input file with the given tag."""
1218
+ """
1219
+ Return the file type class of an input file with the given tag.
1220
+
1221
+ Parameters
1222
+ ----------
1223
+ tag : str
1224
+ The tag of the input file
1225
+
1226
+ Returns
1227
+ -------
1228
+ ftype : FileType
1229
+ The file type class
1230
+ """
1112
1231
  tag = self.get_aliased_tag(tag)
1113
1232
  for t, dt in self.inputs_():
1114
1233
  t = self.get_aliased_tag(t)
@@ -1117,7 +1236,19 @@ I currently know about these stages:
1117
1236
  raise ValueError(f"Tag {tag} is not a known input") # pragma: no cover
1118
1237
 
1119
1238
  def get_output_type(self, tag):
1120
- """Return the file type class of an output file with the given tag."""
1239
+ """
1240
+ Return the file type class of an output file with the given tag.
1241
+
1242
+ Parameters
1243
+ ----------
1244
+ tag : str
1245
+ The tag of the output file
1246
+
1247
+ Returns
1248
+ -------
1249
+ ftype : FileType
1250
+ The file type class
1251
+ """
1121
1252
  tag = self.get_aliased_tag(tag)
1122
1253
  for t, dt in self.outputs_():
1123
1254
  t = self.get_aliased_tag(t)
@@ -1137,8 +1268,12 @@ I currently know about these stages:
1137
1268
  @property
1138
1269
  def config(self):
1139
1270
  """
1140
- Returns the configuration dictionary for this stage, aggregating command
1271
+ The configuration dictionary for this stage, aggregating command
1141
1272
  line options and optional configuration file.
1273
+
1274
+ Options specified in the subclass variable `config_options` are
1275
+ read from the configuration file, command line, or `make_stage` choices,
1276
+ and stored in this dictionary.
1142
1277
  """
1143
1278
  return self._configs
1144
1279
 
@@ -1256,12 +1391,6 @@ I currently know about these stages:
1256
1391
  f"{tag:20} : {aliased_tag:20} :{str(ftype):20} : {self._outputs[aliased_tag]}\n"
1257
1392
  )
1258
1393
 
1259
- def should_skip(self, run_config):
1260
- """Return true if we should skip a stage b/c it's outputs already exist and we are in resume mode"""
1261
- outputs = self.find_outputs(run_config["output_dir"]).values()
1262
- already_run_stage = all(os.path.exists(output) for output in outputs)
1263
- return already_run_stage and run_config["resume"]
1264
-
1265
1394
  def already_finished(self):
1266
1395
  """Print a warning that a stage is being skipped"""
1267
1396
  print(f"Skipping stage {self.instance_name} because its outputs exist already")
@@ -1273,6 +1402,7 @@ I currently know about these stages:
1273
1402
  Loop through chunks of the input data from a FITS file with the given tag
1274
1403
 
1275
1404
  TODO: add ceci tests of this functions
1405
+
1276
1406
  Parameters
1277
1407
  ----------
1278
1408
  tag: str
@@ -1361,6 +1491,59 @@ I currently know about these stages:
1361
1491
  data = {col: group[col][start:end] for col in cols}
1362
1492
  yield start, end, data
1363
1493
 
1494
+ def combined_iterators(self, rows, *inputs, parallel=True):
1495
+ """
1496
+ Iterate through multiple files at the same time.
1497
+
1498
+ If you have more several HDF files with the some
1499
+ columns of the same length then you can use this method to
1500
+ iterate through them all at once, and combine the data from
1501
+ all of them into a single dictionary.
1502
+
1503
+ Parameters
1504
+ ----------
1505
+ rows: int
1506
+ The number of rows to read in each chunk
1507
+
1508
+ *inputs: list
1509
+ A list of (tag, group, cols) triples for each file to read.
1510
+ In each case tag is the input file name tag, group is the
1511
+ group within the HDF5 file to read, and cols is a list of
1512
+ columns to read from that group. Specify multiple triplets
1513
+ to read from multiple files
1514
+
1515
+ parallel: bool
1516
+ Whether to split up data among processes (parallel=True) or give
1517
+ all processes all data (parallel=False). Default = True.
1518
+
1519
+ Returns
1520
+ -------
1521
+ it: iterator
1522
+ Iterator yielding (int, int, dict) tuples of (start, end, data)
1523
+ """
1524
+ if not len(inputs) % 3 == 0:
1525
+ raise ValueError(
1526
+ "Arguments to combined_iterators should be in threes: "
1527
+ "tag, group, value"
1528
+ )
1529
+ n = len(inputs) // 3
1530
+
1531
+ iterators = []
1532
+ for i in range(n):
1533
+ tag = inputs[3 * i]
1534
+ section = inputs[3 * i + 1]
1535
+ cols = inputs[3 * i + 2]
1536
+ iterators.append(
1537
+ self.iterate_hdf(tag, section, cols, rows, parallel=parallel)
1538
+ )
1539
+
1540
+ for it in zip(*iterators):
1541
+ data = {}
1542
+ for (s, e, d) in it:
1543
+ data.update(d)
1544
+ yield s, e, data
1545
+
1546
+
1364
1547
  ################################
1365
1548
  # Pipeline-related methods
1366
1549
  ################################
@@ -1560,3 +1743,52 @@ I currently know about these stages:
1560
1743
  # cwl_tool.metadata = cwlgen.Metadata(**metadata)
1561
1744
 
1562
1745
  return cwl_tool
1746
+
1747
+
1748
+ def time_stamp(self, tag):
1749
+ """
1750
+ Print a time stamp with an optional tag.
1751
+
1752
+ Parameters
1753
+ ----------
1754
+ tag: str
1755
+ Additional info to print in the output line. Default is empty.
1756
+ """
1757
+ t = datetime.datetime.now()
1758
+ print(f"Process {self.rank}: {tag} {t}")
1759
+ sys.stdout.flush()
1760
+
1761
+ def memory_report(self, tag=None):
1762
+ """
1763
+ Print a report about memory currently available
1764
+ on the node the process is running on.
1765
+
1766
+ Parameters
1767
+ ----------
1768
+ tag: str
1769
+ Additional info to print in the output line. Default is empty.
1770
+ """
1771
+ import psutil
1772
+
1773
+ t = datetime.datetime.now()
1774
+
1775
+ # The different types of memory are really fiddly and don't
1776
+ # correspond to how you usually imagine. The simplest thing
1777
+ # to report here is just how much memory is left on the machine.
1778
+ mem = psutil.virtual_memory()
1779
+ avail = mem.available / 1024**3
1780
+ total = mem.total / 1024**3
1781
+
1782
+ if tag is None:
1783
+ tag = ""
1784
+ else:
1785
+ tag = f" {tag}:"
1786
+
1787
+ # This gives you the name of the host. At NERSC that is the node name
1788
+ host = socket.gethostname()
1789
+
1790
+ # Print messsage
1791
+ print(
1792
+ f"{t}: Process {self.rank}:{tag} Remaining memory on {host} {avail:.1f} GB / {total:.1f} GB"
1793
+ )
1794
+ sys.stdout.flush()
@@ -77,7 +77,8 @@ def update_pipeline_file_group(pipeline_files):
77
77
  def scan_directory_and_update(base_dir):
78
78
  groups = collections.defaultdict(list)
79
79
  yaml = ruamel.yaml.YAML()
80
- for dirpath, subdirs, filenames in os.walk(base_dir):
80
+ yaml.allow_duplicate_keys = True
81
+ for dirpath, _, filenames in os.walk(base_dir):
81
82
  # just process yaml files
82
83
  for filename in filenames:
83
84
  if not (filename.endswith(".yaml") or filename.endswith(".yml")):
@@ -85,7 +86,11 @@ def scan_directory_and_update(base_dir):
85
86
  filepath = os.path.join(dirpath, filename)
86
87
  with open(filepath) as f:
87
88
  yaml_str = f.read()
88
- info = yaml.load(yaml_str)
89
+ try:
90
+ info = yaml.load(yaml_str)
91
+ except:
92
+ print("# Could not read yaml file:", filepath)
93
+ continue
89
94
 
90
95
  if is_pipeline_file(info):
91
96
  config = info["config"]
@@ -3,6 +3,8 @@
3
3
  from contextlib import contextmanager
4
4
  import sys
5
5
  import os
6
+ import signal
7
+ import faulthandler
6
8
 
7
9
 
8
10
  def add_python_path(path, start):
@@ -107,3 +109,9 @@ def embolden(text):
107
109
  Emboldened text
108
110
  """
109
111
  return "\033[1m" + text + "\033[0m"
112
+
113
+
114
+ def activate_tracing(rank):
115
+ pid = os.getpid()
116
+ print(f"Activating trace mode for process {rank}. Trigger trace using command: kill -s {signal.SIGUSR1} {pid}")
117
+ faulthandler.register(signal.SIGUSR1)
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.2
2
2
  Name: ceci
3
- Version: 2.0
3
+ Version: 2.1
4
4
  Summary: Lightweight pipeline engine for LSST DESC
5
5
  Author: Joe Zuntz
6
6
  License: BSD 3-Clause License
@@ -137,4 +137,3 @@ def test_inter_pipe():
137
137
 
138
138
  if __name__ == "__main__":
139
139
  test_config()
140
- test_interactive()
@@ -130,7 +130,7 @@ class FailingStage(PipelineStage):
130
130
 
131
131
 
132
132
  def _return_value_test_(resume):
133
- expected_status = 0 if resume else 1
133
+ expected_status = 0 if ((resume == "resume") or (resume is True)) else 1
134
134
  # Mini pipeline should not run
135
135
  launcher_config = {"interval": 0.5, "name": "mini"}
136
136
  run_config = {
@@ -159,10 +159,28 @@ def _return_value_test_(resume):
159
159
 
160
160
  def test_resume():
161
161
  _return_value_test_(True)
162
+ _return_value_test_("resume")
162
163
 
163
164
 
164
165
  def test_fail():
165
166
  _return_value_test_(False)
167
+ _return_value_test_("restart")
168
+
169
+ def test_refuse():
170
+ launcher_config = {"interval": 0.5, "name": "mini"}
171
+ run_config = {
172
+ "log_dir": "./tests/logs",
173
+ "output_dir": "./tests/inputs",
174
+ "resume": "refuse",
175
+ }
176
+
177
+ pipeline = MiniPipeline([{"name": "FailingStage"}], launcher_config)
178
+
179
+ with pytest.raises(RuntimeError) as excinfo:
180
+ pipeline.initialize({}, run_config, "tests/config.yml")
181
+ pipeline.run()
182
+
183
+ assert "Output files already exist for stage" in str(excinfo.value)
166
184
 
167
185
 
168
186
  def test_dry_run(mocker):
@@ -5,8 +5,12 @@ import numpy as np
5
5
  from ceci.errors import *
6
6
  import pytest
7
7
  import h5py
8
+ import sys
8
9
  import os
9
-
10
+ import tempfile
11
+ import time
12
+ import subprocess
13
+ import signal
10
14
  # TODO: test MPI facilities properly with:
11
15
  # https://github.com/rmjarvis/TreeCorr/blob/releases/4.1/tests/mock_mpi.py
12
16
 
@@ -568,9 +572,79 @@ def test_wrong_mpi_flag():
568
572
  with pytest.raises(ValueError):
569
573
  assert LimaSerial.parse_command_line(["LimaSerial", "--mpi"]).mpi
570
574
 
575
+ @pytest.mark.skipif(os.environ.get("GITHUB_ACTIONS") == "true", reason="Doesn't work on github actions")
576
+ def test_tracing():
577
+ with open("mike_stage.py", "w") as f:
578
+ f.write("""
579
+ import ceci
580
+ import time
581
+ class Mike(ceci.PipelineStage):
582
+ name = "Mike"
583
+ inputs = []
584
+ outputs = []
585
+ config_options = {}
586
+
587
+ def run(self):
588
+ time.sleep(6)
589
+ print("Mike complete")
571
590
 
572
- # could add more tests here for constructor, but the regression tests here and in TXPipe are
573
- # pretty thorough.
591
+ if __name__ == "__main__":
592
+ ceci.PipelineStage.main()
593
+ """)
594
+ with open("config.yml", "w") as f:
595
+ f.write("{}")
596
+
597
+ cmd = f"{sys.executable} mike_stage.py Mike --config config.yml --trace"
598
+ p1 = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
599
+ time.sleep(1)
600
+ p1.send_signal(signal.SIGUSR1)
601
+ try:
602
+ outs, _ = p1.communicate(timeout=15)
603
+ except subprocess.TimeoutExpired:
604
+ p1.kill()
605
+ outs, _ = p1.communicate()
606
+ raise ValueError("Timeout expired in Mike test with outs =" + outs.decode())
607
+ assert 'mike_stage.py", line 15' in outs.decode()
608
+
609
+
610
+ def test_combined_iterators():
611
+ class Oscar(PipelineStage):
612
+ inputs = [("inp1", HDFFile), ("inp2", HDFFile)]
613
+ outputs = []
614
+ config_options = {}
615
+ def run(self):
616
+ it = self.combined_iterators(
617
+ 10,
618
+ "inp1", "group1", ["x"],
619
+ "inp2", "group1", ["y", "z"])
620
+ for (s, e, data) in it:
621
+ pass
622
+
623
+ oo = Oscar.make_stage(inp1="tests/test.hdf5", inp2="tests/test.hdf5")
624
+ oo.run()
625
+
626
+
627
+ def test_memory_and_time_reports(capsys):
628
+ class November(PipelineStage):
629
+ name = f"November"
630
+ parallel = False
631
+ inputs = []
632
+ outputs = []
633
+ config_options = {}
634
+
635
+ def run(self):
636
+ self.memory_report()
637
+ self.memory_report("TAGTAG")
638
+ self.time_stamp("Hello")
639
+
640
+
641
+ nn = November.make_stage()
642
+ nn.run()
643
+ captured = capsys.readouterr()
644
+ assert "Remaining memory on" in captured.out
645
+ assert "TAGTAG" in captured.out
646
+ assert "Process 0: Hello" in captured.out
647
+
574
648
 
575
649
  if __name__ == "__main__":
576
650
  test_construct()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes