ceci 2.0.1__tar.gz → 2.2__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.1 → ceci-2.2}/PKG-INFO +2 -2
  2. {ceci-2.0.1 → ceci-2.2}/ceci/_version.py +2 -2
  3. {ceci-2.0.1 → ceci-2.2}/ceci/config.py +41 -0
  4. {ceci-2.0.1 → ceci-2.2}/ceci/minirunner.py +25 -7
  5. {ceci-2.0.1 → ceci-2.2}/ceci/pipeline.py +84 -8
  6. {ceci-2.0.1 → ceci-2.2}/ceci/stage.py +274 -38
  7. {ceci-2.0.1 → ceci-2.2}/ceci/utils.py +8 -0
  8. {ceci-2.0.1 → ceci-2.2}/ceci.egg-info/PKG-INFO +2 -2
  9. {ceci-2.0.1 → ceci-2.2}/tests/test_pipeline.py +19 -1
  10. {ceci-2.0.1 → ceci-2.2}/tests/test_stage.py +78 -2
  11. {ceci-2.0.1 → ceci-2.2}/.coveragerc +0 -0
  12. {ceci-2.0.1 → ceci-2.2}/.github/workflows/ci.yml +0 -0
  13. {ceci-2.0.1 → ceci-2.2}/.github/workflows/publish.yml +0 -0
  14. {ceci-2.0.1 → ceci-2.2}/.gitignore +0 -0
  15. {ceci-2.0.1 → ceci-2.2}/.pylintrc +0 -0
  16. {ceci-2.0.1 → ceci-2.2}/LICENSE +0 -0
  17. {ceci-2.0.1 → ceci-2.2}/README.md +0 -0
  18. {ceci-2.0.1 → ceci-2.2}/ceci/__init__.py +0 -0
  19. {ceci-2.0.1 → ceci-2.2}/ceci/__main__.py +0 -0
  20. {ceci-2.0.1 → ceci-2.2}/ceci/errors.py +0 -0
  21. {ceci-2.0.1 → ceci-2.2}/ceci/main.py +0 -0
  22. {ceci-2.0.1 → ceci-2.2}/ceci/monitor.py +0 -0
  23. {ceci-2.0.1 → ceci-2.2}/ceci/sites/__init__.py +0 -0
  24. {ceci-2.0.1 → ceci-2.2}/ceci/sites/ccin2p3.py +0 -0
  25. {ceci-2.0.1 → ceci-2.2}/ceci/sites/local.py +0 -0
  26. {ceci-2.0.1 → ceci-2.2}/ceci/sites/nersc.py +0 -0
  27. {ceci-2.0.1 → ceci-2.2}/ceci/sites/site.py +0 -0
  28. {ceci-2.0.1 → ceci-2.2}/ceci/tools/__init__.py +0 -0
  29. {ceci-2.0.1 → ceci-2.2}/ceci/tools/ancestors.py +0 -0
  30. {ceci-2.0.1 → ceci-2.2}/ceci/update_pipelines_for_ceci_2.py +0 -0
  31. {ceci-2.0.1 → ceci-2.2}/ceci.egg-info/SOURCES.txt +0 -0
  32. {ceci-2.0.1 → ceci-2.2}/ceci.egg-info/dependency_links.txt +0 -0
  33. {ceci-2.0.1 → ceci-2.2}/ceci.egg-info/entry_points.txt +0 -0
  34. {ceci-2.0.1 → ceci-2.2}/ceci.egg-info/requires.txt +0 -0
  35. {ceci-2.0.1 → ceci-2.2}/ceci.egg-info/top_level.txt +0 -0
  36. {ceci-2.0.1 → ceci-2.2}/ceci.png +0 -0
  37. {ceci-2.0.1 → ceci-2.2}/ceci_example/__init__.py +0 -0
  38. {ceci-2.0.1 → ceci-2.2}/ceci_example/__main__.py +0 -0
  39. {ceci-2.0.1 → ceci-2.2}/ceci_example/example_stages.py +0 -0
  40. {ceci-2.0.1 → ceci-2.2}/ceci_example/types.py +0 -0
  41. {ceci-2.0.1 → ceci-2.2}/ceci_example_namespace/example_stages.py +0 -0
  42. {ceci-2.0.1 → ceci-2.2}/do_cover.sh +0 -0
  43. {ceci-2.0.1 → ceci-2.2}/docs/Makefile +0 -0
  44. {ceci-2.0.1 → ceci-2.2}/docs/conf.py +0 -0
  45. {ceci-2.0.1 → ceci-2.2}/docs/config.rst +0 -0
  46. {ceci-2.0.1 → ceci-2.2}/docs/config2.rst +0 -0
  47. {ceci-2.0.1 → ceci-2.2}/docs/index.rst +0 -0
  48. {ceci-2.0.1 → ceci-2.2}/docs/installation.rst +0 -0
  49. {ceci-2.0.1 → ceci-2.2}/docs/launchers.rst +0 -0
  50. {ceci-2.0.1 → ceci-2.2}/docs/logo.png +0 -0
  51. {ceci-2.0.1 → ceci-2.2}/docs/overview.rst +0 -0
  52. {ceci-2.0.1 → ceci-2.2}/docs/sites.rst +0 -0
  53. {ceci-2.0.1 → ceci-2.2}/docs/stages.rst +0 -0
  54. {ceci-2.0.1 → ceci-2.2}/docs/tutorial.rst +0 -0
  55. {ceci-2.0.1 → ceci-2.2}/nb/ceci_interactive_example.ipynb +0 -0
  56. {ceci-2.0.1 → ceci-2.2}/pyproject.toml +0 -0
  57. {ceci-2.0.1 → ceci-2.2}/setup.cfg +0 -0
  58. {ceci-2.0.1 → ceci-2.2}/setup.py +0 -0
  59. {ceci-2.0.1 → ceci-2.2}/tests/__init__.py +0 -0
  60. {ceci-2.0.1 → ceci-2.2}/tests/config.yml +0 -0
  61. {ceci-2.0.1 → ceci-2.2}/tests/config_nm.yml +0 -0
  62. {ceci-2.0.1 → ceci-2.2}/tests/create_registry_entries.py +0 -0
  63. {ceci-2.0.1 → ceci-2.2}/tests/inputs/dm.txt +0 -0
  64. {ceci-2.0.1 → ceci-2.2}/tests/inputs/fiducial_cosmology.txt +0 -0
  65. {ceci-2.0.1 → ceci-2.2}/tests/test.hdf5 +0 -0
  66. {ceci-2.0.1 → ceci-2.2}/tests/test.yml +0 -0
  67. {ceci-2.0.1 → ceci-2.2}/tests/test_cmd.py +0 -0
  68. {ceci-2.0.1 → ceci-2.2}/tests/test_dask.py +0 -0
  69. {ceci-2.0.1 → ceci-2.2}/tests/test_dataregistry.yml +0 -0
  70. {ceci-2.0.1 → ceci-2.2}/tests/test_executables.py +0 -0
  71. {ceci-2.0.1 → ceci-2.2}/tests/test_flow_chart.py +0 -0
  72. {ceci-2.0.1 → ceci-2.2}/tests/test_helpers.py +0 -0
  73. {ceci-2.0.1 → ceci-2.2}/tests/test_interactive.py +0 -0
  74. {ceci-2.0.1 → ceci-2.2}/tests/test_main.py +0 -0
  75. {ceci-2.0.1 → ceci-2.2}/tests/test_minirunner.py +0 -0
  76. {ceci-2.0.1 → ceci-2.2}/tests/test_monitor.py +0 -0
  77. {ceci-2.0.1 → ceci-2.2}/tests/test_namespace.yml +0 -0
  78. {ceci-2.0.1 → ceci-2.2}/tests/test_python_paths.py +0 -0
  79. {ceci-2.0.1 → ceci-2.2}/tests/test_site.py +0 -0
  80. {ceci-2.0.1 → ceci-2.2}/tests/test_snapshot.py +0 -0
  81. {ceci-2.0.1 → ceci-2.2}/tests/test_update_script.py +0 -0
  82. {ceci-2.0.1 → ceci-2.2}/tests/test_utils.py +0 -0
  83. {ceci-2.0.1 → ceci-2.2}/tests/update_test_files/config.yml +0 -0
  84. {ceci-2.0.1 → ceci-2.2}/tests/update_test_files/pipeline1.yml +0 -0
  85. {ceci-2.0.1 → ceci-2.2}/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.1
3
+ Version: 2.2
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.1'
16
- __version_tuple__ = version_tuple = (2, 0, 1)
15
+ __version__ = version = '2.2'
16
+ __version_tuple__ = version_tuple = (2, 2)
@@ -88,6 +88,10 @@ class StageParameter:
88
88
  self._required = self._default is None
89
89
  self._value = cast_value(self._dtype, self._default)
90
90
 
91
+ def __repr__(self):
92
+ req_str = 'required' if self.required else 'optional'
93
+ return f"Parameter({self.msg}, type: {self.dtype}, default: {self.default} [{req_str}])"
94
+
91
95
  @property
92
96
  def value(self):
93
97
  """Return the value"""
@@ -138,6 +142,27 @@ class StageParameter:
138
142
  self._default = default
139
143
  self.set_to_default()
140
144
 
145
+ def numpy_style_help_text(self):
146
+ """Create a docstring followwing numpy style guidelines"""
147
+ s = ""
148
+ if self._dtype is None:
149
+ s += "[type not specified] "
150
+ else:
151
+ s += f"[{self._dtype.__name__}] "
152
+ if self._required:
153
+ s += "(required)"
154
+ else:
155
+ # truncate long dicts
156
+ if isinstance(self._default, dict) and len(self._default) > 10:
157
+ s += "(default={...})"
158
+ # truncate long lists
159
+ elif isinstance(self._default, list) and len(self._default) > 10:
160
+ s += "(default=[...])"
161
+ else:
162
+ s += f"default={self._default}"
163
+ s += f"\n {self._help}"
164
+ return s
165
+
141
166
 
142
167
  class StageConfig(dict):
143
168
  """A small class to manage a dictionary of configuration parameters with basic type checking"""
@@ -279,3 +304,19 @@ class StageConfig(dict):
279
304
  if isinstance(attr, StageParameter):
280
305
  return attr.dtype
281
306
  return type(attr)
307
+
308
+ def numpy_style_help_text(self):
309
+ """Create a docstring followwing numpy style guidelines"""
310
+ if not self:
311
+ return "<This class has no configuration options>"
312
+ s = []
313
+ for key, val in dict.items(self):
314
+ if isinstance(val, StageConfig):
315
+ val = val[key]
316
+ if isinstance(val, StageParameter):
317
+ s.append(f"{key}: {val.numpy_style_help_text()}")
318
+ elif isinstance(val, type):
319
+ s.append(f"{key}: {val.__name__}] (required)")
320
+ else:
321
+ s.append(f"{key}: {type(val).__name__}] (default={val})")
322
+ return '\n\n'.join(s)
@@ -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
@@ -364,11 +369,16 @@ class Pipeline:
364
369
  run_config = {
365
370
  "output_dir": pipe_config.get("output_dir", "."),
366
371
  "log_dir": pipe_config.get("log_dir", "."),
367
- "resume": pipe_config.get("resume", False),
372
+ "resume": pipe_config.get("resume", RESUME_MODE_RESUME),
368
373
  "flow_chart": pipe_config.get("flow_chart", ""),
369
374
  "registry": pipe_config.get("registry", None),
370
375
  }
371
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
+
372
382
  launcher_dict = dict(cwl=CWLPipeline, parsl=ParslPipeline, mini=MiniPipeline)
373
383
 
374
384
  if pipe_config.get("flow_chart", False):
@@ -514,18 +524,23 @@ class Pipeline:
514
524
  else:
515
525
  raise ValueError("Must specify either id or name in registry lookup")
516
526
 
527
+ # Whatever the lookup, we always require a dataset which has not beed deleted or replaced
528
+ status_filter = registry.Query.gen_filter("dataset.status", "==", 1)
529
+
517
530
  # Main finder method
518
- results = registry.Query.find_datasets(["dataset.dataset_id"], [filter])
531
+ results = registry.Query.find_datasets(["dataset.dataset_id"], [filter, status_filter])
519
532
 
520
533
  # Check that we find exactly one dataset matching the query
521
- results = list(results)
522
- if len(results) == 0:
534
+ if not results:
523
535
  raise ValueError(f"Could not find any dataset matching {info} in registry")
524
- elif len(results) > 1:
536
+
537
+ results = results['dataset.dataset_id']
538
+
539
+ if len(results) > 1:
525
540
  raise ValueError(f"Found multiple datasets matching {info} in registry")
526
541
 
527
542
  # Get the absolute path
528
- return registry.Query.get_dataset_absolute_path(results[0].dataset_id)
543
+ return registry.Query.get_dataset_absolute_path(results[0])
529
544
 
530
545
 
531
546
  def process_overall_inputs(self, inputs):
@@ -820,7 +835,9 @@ class Pipeline:
820
835
  # In interactive use the stage object might already be created
821
836
  # otherwise we need to create it
822
837
  if sec.stage_obj is not None:
838
+ orig_stage_config = stages_config_data.get(stage_name, {}).copy()
823
839
  stage = sec.stage_obj
840
+ stage.config.update(**orig_stage_config)
824
841
  else:
825
842
  # Find the inputs for this stage and set up the arguments
826
843
  # to the stage init method
@@ -949,7 +966,24 @@ class Pipeline:
949
966
 
950
967
  def should_skip_stage(self, stage):
951
968
  """Return true if we should skip a stage because it is finished and we are in resume mode"""
952
- return stage.should_skip(self.run_config)
969
+ resume_mode = self.run_config["resume"]
970
+ if resume_mode is True:
971
+ resume_mode = "resume"
972
+ elif resume_mode is False:
973
+ resume_mode = "restart"
974
+
975
+ if resume_mode == "restart":
976
+ return False
977
+
978
+ outputs = stage.find_outputs(self.run_config["output_dir"]).values()
979
+
980
+ if resume_mode == "resume":
981
+ return all(os.path.exists(output) for output in outputs)
982
+ elif resume_mode == "refuse":
983
+ if any(os.path.exists(output) for output in outputs):
984
+ raise RuntimeError(f"Output files already exist for stage {stage} and resume mode is 'refuse'")
985
+ else:
986
+ raise ValueError(f"Unknown resume mode: {resume_mode}")
953
987
 
954
988
  def save(self, pipefile, stagefile=None, reduce_config=False, **kwargs):
955
989
  """Save this pipeline state to a yaml file
@@ -1039,6 +1073,10 @@ class Pipeline:
1039
1073
  # Nodes we have already added
1040
1074
  seen = set()
1041
1075
 
1076
+ # Dictionary to track nodes by their inputs and outputs
1077
+ node_groups = {}
1078
+
1079
+
1042
1080
  # Add overall pipeline inputs
1043
1081
  for inp in self.overall_inputs.keys():
1044
1082
  graph.add_node(inp, shape="box", color="gold", style="filled")
@@ -1064,6 +1102,43 @@ class Pipeline:
1064
1102
  seen.add(out)
1065
1103
  graph.add_edge(stage.instance_name, out, color="black")
1066
1104
 
1105
+ # We want to group together all the files that all created
1106
+ # by the same stage and also all used by the same stages, to
1107
+ # reduce the number of nodes in the graph and make it more readable.
1108
+ # First we find that grouping.
1109
+ node_groups = collections.defaultdict(list)
1110
+ for node in graph.nodes_iter():
1111
+ # only affect the nodes representing files
1112
+ if node.attr['color'] != "skyblue":
1113
+ continue
1114
+ # Find the stage node that created this file,
1115
+ # and all the stage nodes that make use of it
1116
+ edge_in = graph.in_edges(node)[0]
1117
+ creator = edge_in[0]
1118
+ users = []
1119
+ for edge in graph.out_edges(node):
1120
+ users.append(edge[1])
1121
+ key = (creator, tuple(users))
1122
+ node_groups[key].append(node)
1123
+
1124
+ # Now we remove all the groups of nodes with more than one in
1125
+ # and replace them with a single node
1126
+ for key, nodes in node_groups.items():
1127
+ if len(nodes) > 1:
1128
+ if len(nodes) > 4:
1129
+ # make a string with two nodes per line
1130
+ node_names = []
1131
+ for i in range(0, len(nodes), 2):
1132
+ node_names.append(", ".join(nodes[i:i+2]))
1133
+ new_node = "\n".join(node_names)
1134
+ else:
1135
+ new_node = "\n".join(nodes)
1136
+ graph.remove_nodes_from(nodes)
1137
+ graph.add_node(new_node, shape="box", color="skyblue", style="filled")
1138
+ graph.add_edge(key[0], new_node, color="black")
1139
+ for user in key[1]:
1140
+ graph.add_edge(new_node, user, color="black")
1141
+
1067
1142
  # finally, output the stage to file
1068
1143
  if filename.endswith(".dot"):
1069
1144
  graph.write(filename)
@@ -1432,6 +1507,7 @@ class MiniPipeline(Pipeline):
1432
1507
  f"""
1433
1508
  *************************************************
1434
1509
  Error running pipeline stage {error.job_name}.
1510
+ Failed after {error.run_time}.
1435
1511
 
1436
1512
  Standard output and error streams in {log_dir}/{error.job_name}.out
1437
1513
  *************************************************
@@ -9,11 +9,13 @@ import cProfile
9
9
  import pdb
10
10
  import datetime
11
11
  import warnings
12
+ import socket
12
13
 
13
14
  from abc import abstractmethod
14
15
  from . import errors
15
16
  from .monitor import MemoryMonitor
16
17
  from .config import StageParameter, StageConfig, cast_to_streamable
18
+ from .utils import activate_tracing
17
19
 
18
20
  SERIAL = "serial"
19
21
  MPI_PARALLEL = "mpi"
@@ -146,9 +148,19 @@ class PipelineStage:
146
148
 
147
149
  @abstractmethod
148
150
  def run(self): # pragma: no cover
149
- """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
+ """
150
155
  raise NotImplementedError("run")
151
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
163
+
152
164
  def load_configs(self, args):
153
165
  """
154
166
  Load the configuraiton
@@ -340,14 +352,15 @@ class PipelineStage:
340
352
  path = pathlib.Path(filename).resolve()
341
353
 
342
354
  # Add a description of the parameters to the end of the docstring
343
- if stage_is_complete:
355
+ # If no config options are specified, omit this.
356
+ if stage_is_complete and cls.config_options:
344
357
  config_text = cls._describe_configuration_text()
345
358
  if cls.__doc__ is None:
346
- cls.__doc__ = f"Stage {cls.name}\n\nConfiguration Parameters:\n{config_text}"
359
+ cls.__doc__ = f"Stage {cls.name}\n\nParameters\n----------\n{config_text}"
347
360
  else:
348
361
  # strip any existing configuration text from parent classes that is at the end of the doctring
349
- cls.__doc__ = cls.__doc__.split("Configuration Parameters:")[0]
350
- cls.__doc__ += f"\n\nConfiguration Parameters:\n{config_text}"
362
+ cls.__doc__ = cls.__doc__.split("Parameters")[0]
363
+ cls.__doc__ += f"\n\nParameters\n----------\n{config_text}"
351
364
 
352
365
  # Register the class
353
366
  if stage_is_complete:
@@ -423,24 +436,24 @@ class PipelineStage:
423
436
  s = []
424
437
  if cls.config_options is None:
425
438
  return "<This class has no configuration options>"
439
+
426
440
  for name, val in cls.config_options.items():
441
+ if isinstance(val, StageConfig):
442
+ val = val[name]
427
443
  if isinstance(val, StageParameter):
428
- if val.required:
429
- if val.dtype is None:
430
- txt = f"[type not specified]: {val._help} (required)"
431
- else:
432
- txt = f"[{val.dtype.__name__}]: {val._help} (required)"
433
- else:
434
- if val.dtype is None:
435
- txt = f"[type not specified]: {val._help} (default={val.default})"
436
- else:
437
- txt = f"[{val.dtype.__name__}]: {val._help} (default={val.default})"
444
+ s.append(f"{name}: {val.numpy_style_help_text()}")
438
445
  elif isinstance(val, type):
439
- txt = f"[{val.__name__}]: (required)"
446
+ s.append(f"{name}: {val.__name__}] (required)")
440
447
  else:
441
- txt = f"[{type(val).__name__}]: (default={val})"
442
- s.append(f"{name} {txt} ")
443
- return '\n'.join(s)
448
+ s.append(f"{name}: {type(val).__name__}] (default={val})")
449
+
450
+ for input_ in cls.inputs:
451
+ s.append(f"{input_[0]}: {input_[1].__name__} (INPUT)")
452
+ for output_ in cls.outputs:
453
+ s.append(f"{output_[0]}: {output_[1].__name__} (OUTPUT)")
454
+
455
+ return '\n\n'.join(s)
456
+
444
457
 
445
458
  @classmethod
446
459
  def usage(cls): # pragma: no cover
@@ -603,6 +616,12 @@ I currently know about these stages:
603
616
  help="Report memory use. Argument gives interval in seconds between reports",
604
617
  )
605
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
+
606
625
  # Error message we will return if --mpi used on a non-supported
607
626
  # stage.
608
627
  mpi_err = (
@@ -661,6 +680,21 @@ I currently know about these stages:
661
680
  if args.memmon: # pragma: no cover
662
681
  monitor = MemoryMonitor.start_in_thread(interval=args.memmon)
663
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
+
664
698
  try:
665
699
  stage.run()
666
700
  except Exception as error: # pragma: no cover
@@ -781,13 +815,24 @@ I currently know about these stages:
781
815
 
782
816
  def is_mpi(self):
783
817
  """
784
- 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
785
824
  """
786
825
  return self._parallel == MPI_PARALLEL
787
826
 
788
827
  def is_dask(self):
789
828
  """
790
- 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
+
791
836
  """
792
837
  return self._parallel == DASK_PARALLEL
793
838
 
@@ -938,6 +983,11 @@ I currently know about these stages:
938
983
  Parallel: bool
939
984
  Whether to split data by rank or just give all procs all data.
940
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
941
991
  """
942
992
  n_chunks = n_rows // chunk_rows
943
993
  if n_chunks * chunk_rows < n_rows: # pragma: no cover
@@ -959,6 +1009,17 @@ I currently know about these stages:
959
1009
  """
960
1010
  Return the path of an input file with the given tag,
961
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
+
962
1023
  """
963
1024
  tag = self.get_aliased_tag(tag)
964
1025
  return self._inputs[tag]
@@ -971,7 +1032,21 @@ I currently know about these stages:
971
1032
  which can be aliased already.
972
1033
 
973
1034
  If final_name is False then use a temporary name - file will
974
- 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
975
1050
  """
976
1051
 
977
1052
  tag = self.get_aliased_tag(tag)
@@ -994,6 +1069,21 @@ I currently know about these stages:
994
1069
  For specialized file types like FITS or HDF5 it will return
995
1070
  a more specific object - see the types.py file for more info.
996
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
997
1087
  """
998
1088
  path = self.get_input(tag)
999
1089
  input_class = self.get_input_type(tag)
@@ -1010,7 +1100,7 @@ I currently know about these stages:
1010
1100
  Find and open an output file with the given tag, in write mode.
1011
1101
 
1012
1102
  If final_name is True then they will be opened using their final
1013
- target output name. Otherwise we will prepend "inprogress_" to their
1103
+ target output name. Otherwise we will prepend `inprogress_` to their
1014
1104
  file name. This means we know that if the final file exists then it
1015
1105
  is completed.
1016
1106
 
@@ -1021,12 +1111,11 @@ I currently know about these stages:
1021
1111
 
1022
1112
  Parameters
1023
1113
  ----------
1024
-
1025
1114
  tag: str
1026
1115
  Tag as listed in self.outputs
1027
1116
 
1028
1117
  wrapper: bool
1029
- Default=False. Whether to return a wrapped file
1118
+ Whether to return an underlying file object (False) or a data type instance (True)
1030
1119
 
1031
1120
  final_name: bool
1032
1121
  Default=False. Whether to save to
@@ -1034,6 +1123,10 @@ I currently know about these stages:
1034
1123
  **kwargs:
1035
1124
  Extra args are passed on to the file's class constructor.
1036
1125
 
1126
+ Returns
1127
+ -------
1128
+ obj: file or object
1129
+ The opened file or object
1037
1130
  """
1038
1131
  path = self.get_output(tag, final_name=final_name)
1039
1132
  output_class = self.get_output_type(tag)
@@ -1078,33 +1171,63 @@ I currently know about these stages:
1078
1171
  @classmethod
1079
1172
  def inputs_(cls):
1080
1173
  """
1081
- 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]
1082
1179
  """
1083
1180
  return cls.inputs # pylint: disable=no-member
1084
1181
 
1085
1182
  @classmethod
1086
1183
  def outputs_(cls):
1087
1184
  """
1088
- 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]
1089
1190
  """
1090
1191
  return cls.outputs # pylint: disable=no-member
1091
1192
 
1092
1193
  @classmethod
1093
1194
  def output_tags(cls):
1094
1195
  """
1095
- 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
1096
1202
  """
1097
1203
  return [tag for tag, _ in cls.outputs_()]
1098
1204
 
1099
1205
  @classmethod
1100
1206
  def input_tags(cls):
1101
1207
  """
1102
- 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
1103
1214
  """
1104
1215
  return [tag for tag, _ in cls.inputs_()]
1105
1216
 
1106
1217
  def get_input_type(self, tag):
1107
- """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
+ """
1108
1231
  tag = self.get_aliased_tag(tag)
1109
1232
  for t, dt in self.inputs_():
1110
1233
  t = self.get_aliased_tag(t)
@@ -1113,7 +1236,19 @@ I currently know about these stages:
1113
1236
  raise ValueError(f"Tag {tag} is not a known input") # pragma: no cover
1114
1237
 
1115
1238
  def get_output_type(self, tag):
1116
- """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
+ """
1117
1252
  tag = self.get_aliased_tag(tag)
1118
1253
  for t, dt in self.outputs_():
1119
1254
  t = self.get_aliased_tag(t)
@@ -1133,8 +1268,12 @@ I currently know about these stages:
1133
1268
  @property
1134
1269
  def config(self):
1135
1270
  """
1136
- Returns the configuration dictionary for this stage, aggregating command
1271
+ The configuration dictionary for this stage, aggregating command
1137
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.
1138
1277
  """
1139
1278
  return self._configs
1140
1279
 
@@ -1252,12 +1391,6 @@ I currently know about these stages:
1252
1391
  f"{tag:20} : {aliased_tag:20} :{str(ftype):20} : {self._outputs[aliased_tag]}\n"
1253
1392
  )
1254
1393
 
1255
- def should_skip(self, run_config):
1256
- """Return true if we should skip a stage b/c it's outputs already exist and we are in resume mode"""
1257
- outputs = self.find_outputs(run_config["output_dir"]).values()
1258
- already_run_stage = all(os.path.exists(output) for output in outputs)
1259
- return already_run_stage and run_config["resume"]
1260
-
1261
1394
  def already_finished(self):
1262
1395
  """Print a warning that a stage is being skipped"""
1263
1396
  print(f"Skipping stage {self.instance_name} because its outputs exist already")
@@ -1269,6 +1402,7 @@ I currently know about these stages:
1269
1402
  Loop through chunks of the input data from a FITS file with the given tag
1270
1403
 
1271
1404
  TODO: add ceci tests of this functions
1405
+
1272
1406
  Parameters
1273
1407
  ----------
1274
1408
  tag: str
@@ -1357,6 +1491,59 @@ I currently know about these stages:
1357
1491
  data = {col: group[col][start:end] for col in cols}
1358
1492
  yield start, end, data
1359
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
+
1360
1547
  ################################
1361
1548
  # Pipeline-related methods
1362
1549
  ################################
@@ -1556,3 +1743,52 @@ I currently know about these stages:
1556
1743
  # cwl_tool.metadata = cwlgen.Metadata(**metadata)
1557
1744
 
1558
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()
@@ -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.1
3
+ Version: 2.2
4
4
  Summary: Lightweight pipeline engine for LSST DESC
5
5
  Author: Joe Zuntz
6
6
  License: BSD 3-Clause License
@@ -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
 
@@ -145,7 +149,7 @@ def test_parameter():
145
149
  assert stage_1.config.b == 'puffins are not extinct?'
146
150
  assert 1 in stage_1.config.c
147
151
  assert len(stage_1.config.d) == 0
148
- assert "c [list]: a list (default=[1, 2, 3])" in stage_1.__doc__
152
+ assert "[list] default=[1, 2, 3]" in stage_1.__doc__
149
153
  stage_1.describe_configuration()
150
154
 
151
155
  cmd = "TestStage", "--a", "6", "--b", "puffins are not extinct?", "--inp", "dummy"
@@ -568,7 +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
 
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
+
572
648
 
573
649
  if __name__ == "__main__":
574
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