hpcflow-new2 0.2.0a188__py3-none-any.whl → 0.2.0a190__py3-none-any.whl

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 (115) hide show
  1. hpcflow/__pyinstaller/hook-hpcflow.py +8 -6
  2. hpcflow/_version.py +1 -1
  3. hpcflow/app.py +1 -0
  4. hpcflow/data/scripts/main_script_test_hdf5_in_obj.py +1 -1
  5. hpcflow/data/scripts/main_script_test_hdf5_out_obj.py +1 -1
  6. hpcflow/sdk/__init__.py +21 -15
  7. hpcflow/sdk/app.py +2133 -770
  8. hpcflow/sdk/cli.py +281 -250
  9. hpcflow/sdk/cli_common.py +6 -2
  10. hpcflow/sdk/config/__init__.py +1 -1
  11. hpcflow/sdk/config/callbacks.py +77 -42
  12. hpcflow/sdk/config/cli.py +126 -103
  13. hpcflow/sdk/config/config.py +578 -311
  14. hpcflow/sdk/config/config_file.py +131 -95
  15. hpcflow/sdk/config/errors.py +112 -85
  16. hpcflow/sdk/config/types.py +145 -0
  17. hpcflow/sdk/core/actions.py +1054 -994
  18. hpcflow/sdk/core/app_aware.py +24 -0
  19. hpcflow/sdk/core/cache.py +81 -63
  20. hpcflow/sdk/core/command_files.py +275 -185
  21. hpcflow/sdk/core/commands.py +111 -107
  22. hpcflow/sdk/core/element.py +724 -503
  23. hpcflow/sdk/core/enums.py +192 -0
  24. hpcflow/sdk/core/environment.py +74 -93
  25. hpcflow/sdk/core/errors.py +398 -51
  26. hpcflow/sdk/core/json_like.py +540 -272
  27. hpcflow/sdk/core/loop.py +380 -334
  28. hpcflow/sdk/core/loop_cache.py +160 -43
  29. hpcflow/sdk/core/object_list.py +370 -207
  30. hpcflow/sdk/core/parameters.py +728 -600
  31. hpcflow/sdk/core/rule.py +59 -41
  32. hpcflow/sdk/core/run_dir_files.py +33 -22
  33. hpcflow/sdk/core/task.py +1546 -1325
  34. hpcflow/sdk/core/task_schema.py +240 -196
  35. hpcflow/sdk/core/test_utils.py +126 -88
  36. hpcflow/sdk/core/types.py +387 -0
  37. hpcflow/sdk/core/utils.py +410 -305
  38. hpcflow/sdk/core/validation.py +82 -9
  39. hpcflow/sdk/core/workflow.py +1192 -1028
  40. hpcflow/sdk/core/zarr_io.py +98 -137
  41. hpcflow/sdk/demo/cli.py +46 -33
  42. hpcflow/sdk/helper/cli.py +18 -16
  43. hpcflow/sdk/helper/helper.py +75 -63
  44. hpcflow/sdk/helper/watcher.py +61 -28
  45. hpcflow/sdk/log.py +83 -59
  46. hpcflow/sdk/persistence/__init__.py +8 -31
  47. hpcflow/sdk/persistence/base.py +988 -586
  48. hpcflow/sdk/persistence/defaults.py +6 -0
  49. hpcflow/sdk/persistence/discovery.py +38 -0
  50. hpcflow/sdk/persistence/json.py +408 -153
  51. hpcflow/sdk/persistence/pending.py +158 -123
  52. hpcflow/sdk/persistence/store_resource.py +37 -22
  53. hpcflow/sdk/persistence/types.py +307 -0
  54. hpcflow/sdk/persistence/utils.py +14 -11
  55. hpcflow/sdk/persistence/zarr.py +477 -420
  56. hpcflow/sdk/runtime.py +44 -41
  57. hpcflow/sdk/submission/{jobscript_info.py → enums.py} +39 -12
  58. hpcflow/sdk/submission/jobscript.py +444 -404
  59. hpcflow/sdk/submission/schedulers/__init__.py +133 -40
  60. hpcflow/sdk/submission/schedulers/direct.py +97 -71
  61. hpcflow/sdk/submission/schedulers/sge.py +132 -126
  62. hpcflow/sdk/submission/schedulers/slurm.py +263 -268
  63. hpcflow/sdk/submission/schedulers/utils.py +7 -2
  64. hpcflow/sdk/submission/shells/__init__.py +14 -15
  65. hpcflow/sdk/submission/shells/base.py +102 -29
  66. hpcflow/sdk/submission/shells/bash.py +72 -55
  67. hpcflow/sdk/submission/shells/os_version.py +31 -30
  68. hpcflow/sdk/submission/shells/powershell.py +37 -29
  69. hpcflow/sdk/submission/submission.py +203 -257
  70. hpcflow/sdk/submission/types.py +143 -0
  71. hpcflow/sdk/typing.py +163 -12
  72. hpcflow/tests/conftest.py +8 -6
  73. hpcflow/tests/schedulers/slurm/test_slurm_submission.py +5 -2
  74. hpcflow/tests/scripts/test_main_scripts.py +60 -30
  75. hpcflow/tests/shells/wsl/test_wsl_submission.py +6 -4
  76. hpcflow/tests/unit/test_action.py +86 -75
  77. hpcflow/tests/unit/test_action_rule.py +9 -4
  78. hpcflow/tests/unit/test_app.py +13 -6
  79. hpcflow/tests/unit/test_cli.py +1 -1
  80. hpcflow/tests/unit/test_command.py +71 -54
  81. hpcflow/tests/unit/test_config.py +20 -15
  82. hpcflow/tests/unit/test_config_file.py +21 -18
  83. hpcflow/tests/unit/test_element.py +58 -62
  84. hpcflow/tests/unit/test_element_iteration.py +3 -1
  85. hpcflow/tests/unit/test_element_set.py +29 -19
  86. hpcflow/tests/unit/test_group.py +4 -2
  87. hpcflow/tests/unit/test_input_source.py +116 -93
  88. hpcflow/tests/unit/test_input_value.py +29 -24
  89. hpcflow/tests/unit/test_json_like.py +44 -35
  90. hpcflow/tests/unit/test_loop.py +65 -58
  91. hpcflow/tests/unit/test_object_list.py +17 -12
  92. hpcflow/tests/unit/test_parameter.py +16 -7
  93. hpcflow/tests/unit/test_persistence.py +48 -35
  94. hpcflow/tests/unit/test_resources.py +20 -18
  95. hpcflow/tests/unit/test_run.py +8 -3
  96. hpcflow/tests/unit/test_runtime.py +2 -1
  97. hpcflow/tests/unit/test_schema_input.py +23 -15
  98. hpcflow/tests/unit/test_shell.py +3 -2
  99. hpcflow/tests/unit/test_slurm.py +8 -7
  100. hpcflow/tests/unit/test_submission.py +39 -19
  101. hpcflow/tests/unit/test_task.py +352 -247
  102. hpcflow/tests/unit/test_task_schema.py +33 -20
  103. hpcflow/tests/unit/test_utils.py +9 -11
  104. hpcflow/tests/unit/test_value_sequence.py +15 -12
  105. hpcflow/tests/unit/test_workflow.py +114 -83
  106. hpcflow/tests/unit/test_workflow_template.py +0 -1
  107. hpcflow/tests/workflows/test_jobscript.py +2 -1
  108. hpcflow/tests/workflows/test_workflows.py +18 -13
  109. {hpcflow_new2-0.2.0a188.dist-info → hpcflow_new2-0.2.0a190.dist-info}/METADATA +2 -1
  110. hpcflow_new2-0.2.0a190.dist-info/RECORD +165 -0
  111. hpcflow/sdk/core/parallel.py +0 -21
  112. hpcflow_new2-0.2.0a188.dist-info/RECORD +0 -158
  113. {hpcflow_new2-0.2.0a188.dist-info → hpcflow_new2-0.2.0a190.dist-info}/LICENSE +0 -0
  114. {hpcflow_new2-0.2.0a188.dist-info → hpcflow_new2-0.2.0a190.dist-info}/WHEEL +0 -0
  115. {hpcflow_new2-0.2.0a188.dist-info → hpcflow_new2-0.2.0a190.dist-info}/entry_points.txt +0 -0
@@ -5,22 +5,21 @@ they may be grouped together within a jobscript for efficiency.
5
5
  """
6
6
 
7
7
  from __future__ import annotations
8
+ from collections.abc import Mapping
8
9
  import copy
9
10
  from dataclasses import dataclass
10
- from datetime import datetime
11
- import enum
12
11
  import json
13
12
  from pathlib import Path
14
13
  import re
15
14
  from textwrap import indent, dedent
16
- from typing import Any, Dict, List, Optional, Tuple, Union
17
-
18
- from valida.conditions import ConditionLike
15
+ from typing import cast, final, overload, TYPE_CHECKING
16
+ from typing_extensions import override
19
17
 
20
18
  from watchdog.utils.dirsnapshot import DirectorySnapshotDiff
21
19
 
22
- from hpcflow.sdk import app
23
20
  from hpcflow.sdk.core import ABORT_EXIT_CODE
21
+ from hpcflow.sdk.core.app_aware import AppAware
22
+ from hpcflow.sdk.core.enums import ActionScopeType, EARStatus
24
23
  from hpcflow.sdk.core.errors import (
25
24
  ActionEnvironmentMissingNameError,
26
25
  MissingCompatibleActionEnvironment,
@@ -30,6 +29,8 @@ from hpcflow.sdk.core.errors import (
30
29
  UnsupportedScriptDataFormat,
31
30
  )
32
31
  from hpcflow.sdk.core.json_like import ChildObjectSpec, JSONLike
32
+ from hpcflow.sdk.core.parameters import ParameterValue
33
+ from hpcflow.sdk.typing import ParamSource, hydrate
33
34
  from hpcflow.sdk.core.utils import (
34
35
  JSONLikeDirSnapShot,
35
36
  split_param_label,
@@ -37,129 +38,49 @@ from hpcflow.sdk.core.utils import (
37
38
  )
38
39
  from hpcflow.sdk.log import TimeIt
39
40
  from hpcflow.sdk.core.run_dir_files import RunDirAppFiles
40
-
41
-
42
- ACTION_SCOPE_REGEX = r"(\w*)(?:\[(.*)\])?"
43
-
44
-
45
- class ActionScopeType(enum.Enum):
46
- """
47
- Types of action scope.
48
- """
49
-
50
- #: Scope that applies to anything.
51
- ANY = 0
52
- #: Scope that only applies to main scripts.
53
- MAIN = 1
54
- #: Scope that applies to processing steps.
55
- PROCESSING = 2
56
- #: Scope that applies to input file generators.
57
- INPUT_FILE_GENERATOR = 3
58
- #: Scope that applies to output file parsers.
59
- OUTPUT_FILE_PARSER = 4
41
+ from hpcflow.sdk.submission.enums import SubmissionStatus
42
+
43
+ if TYPE_CHECKING:
44
+ from collections.abc import Callable, Container, Iterable, Iterator, Sequence
45
+ from datetime import datetime
46
+ from re import Pattern
47
+ from typing import Any, ClassVar, Literal
48
+ from typing_extensions import Self
49
+ from valida.conditions import ConditionLike # type: ignore
50
+
51
+ from ..typing import DataIndex, ParamSource
52
+ from ..submission.jobscript import Jobscript
53
+ from .commands import Command
54
+ from .command_files import InputFileGenerator, OutputFileParser, FileSpec
55
+ from .element import (
56
+ Element,
57
+ ElementIteration,
58
+ ElementInputs,
59
+ ElementOutputs,
60
+ ElementResources,
61
+ ElementInputFiles,
62
+ ElementOutputFiles,
63
+ )
64
+ from .environment import Environment
65
+ from .parameters import SchemaParameter, Parameter
66
+ from .rule import Rule
67
+ from .task import WorkflowTask
68
+ from .task_schema import TaskSchema
69
+ from .types import ParameterDependence, ScriptData
70
+ from .workflow import Workflow
60
71
 
61
72
 
62
73
  #: Keyword arguments permitted for particular scopes.
63
- ACTION_SCOPE_ALLOWED_KWARGS = {
64
- ActionScopeType.ANY.name: set(),
65
- ActionScopeType.MAIN.name: set(),
66
- ActionScopeType.PROCESSING.name: set(),
67
- ActionScopeType.INPUT_FILE_GENERATOR.name: {"file"},
68
- ActionScopeType.OUTPUT_FILE_PARSER.name: {"output"},
74
+ ACTION_SCOPE_ALLOWED_KWARGS: Mapping[str, frozenset[str]] = {
75
+ ActionScopeType.ANY.name: frozenset(),
76
+ ActionScopeType.MAIN.name: frozenset(),
77
+ ActionScopeType.PROCESSING.name: frozenset(),
78
+ ActionScopeType.INPUT_FILE_GENERATOR.name: frozenset({"file"}),
79
+ ActionScopeType.OUTPUT_FILE_PARSER.name: frozenset({"output"}),
69
80
  }
70
81
 
71
82
 
72
- class EARStatus(enum.Enum):
73
- """Enumeration of all possible EAR statuses, and their associated status colour."""
74
-
75
- def __new__(cls, value, symbol, colour, doc=None):
76
- member = object.__new__(cls)
77
- member._value_ = value
78
- member.colour = colour
79
- member.symbol = symbol
80
- member.__doc__ = doc
81
- return member
82
-
83
- #: Not yet associated with a submission.
84
- pending = (
85
- 0,
86
- ".",
87
- "grey46",
88
- "Not yet associated with a submission.",
89
- )
90
- #: Associated with a prepared submission that is not yet submitted.
91
- prepared = (
92
- 1,
93
- ".",
94
- "grey46",
95
- "Associated with a prepared submission that is not yet submitted.",
96
- )
97
- #: Submitted for execution.
98
- submitted = (
99
- 2,
100
- ".",
101
- "grey46",
102
- "Submitted for execution.",
103
- )
104
- #: Executing now.
105
- running = (
106
- 3,
107
- "●",
108
- "dodger_blue1",
109
- "Executing now.",
110
- )
111
- #: Not attempted due to a failure of an upstream action on which this depends,
112
- #: or a loop termination condition being satisfied.
113
- skipped = (
114
- 4,
115
- "s",
116
- "dark_orange",
117
- (
118
- "Not attempted due to a failure of an upstream action on which this depends, "
119
- "or a loop termination condition being satisfied."
120
- ),
121
- )
122
- #: Aborted by the user; downstream actions will be attempted.
123
- aborted = (
124
- 5,
125
- "A",
126
- "deep_pink4",
127
- "Aborted by the user; downstream actions will be attempted.",
128
- )
129
- #: Probably exited successfully.
130
- success = (
131
- 6,
132
- "■",
133
- "green3",
134
- "Probably exited successfully.",
135
- )
136
- #: Probably failed.
137
- error = (
138
- 7,
139
- "E",
140
- "red3",
141
- "Probably failed.",
142
- )
143
-
144
- @classmethod
145
- def get_non_running_submitted_states(cls):
146
- """Return the set of all non-running states, excluding those before submission."""
147
- return {
148
- cls.skipped,
149
- cls.aborted,
150
- cls.success,
151
- cls.error,
152
- }
153
-
154
- @property
155
- def rich_repr(self):
156
- """
157
- The rich representation of the value.
158
- """
159
- return f"[{self.colour}]{self.symbol}[/{self.colour}]"
160
-
161
-
162
- class ElementActionRun:
83
+ class ElementActionRun(AppAware):
163
84
  """
164
85
  The Element Action Run (EAR) is an atomic unit of an enacted workflow, representing
165
86
  one unit of work (e.g., particular submitted job to run a program) within that
@@ -204,26 +125,24 @@ class ElementActionRun:
204
125
  Where to run the EAR (if not locally).
205
126
  """
206
127
 
207
- _app_attr = "app"
208
-
209
128
  def __init__(
210
129
  self,
211
130
  id_: int,
212
131
  is_pending: bool,
213
- element_action,
132
+ element_action: ElementAction,
214
133
  index: int,
215
- data_idx: Dict,
216
- commands_idx: List[int],
217
- start_time: Union[datetime, None],
218
- end_time: Union[datetime, None],
219
- snapshot_start: Union[Dict, None],
220
- snapshot_end: Union[Dict, None],
221
- submission_idx: Union[int, None],
222
- success: Union[bool, None],
134
+ data_idx: DataIndex,
135
+ commands_idx: list[int],
136
+ start_time: datetime | None,
137
+ end_time: datetime | None,
138
+ snapshot_start: dict[str, Any] | None,
139
+ snapshot_end: dict[str, Any] | None,
140
+ submission_idx: int | None,
141
+ success: bool | None,
223
142
  skip: bool,
224
- exit_code: Union[int, None],
225
- metadata: Dict,
226
- run_hostname: Union[str, None],
143
+ exit_code: int | None,
144
+ metadata: dict[str, Any],
145
+ run_hostname: str | None,
227
146
  ) -> None:
228
147
  self._id = id_
229
148
  self._is_pending = is_pending
@@ -243,16 +162,16 @@ class ElementActionRun:
243
162
  self._run_hostname = run_hostname
244
163
 
245
164
  # assigned on first access of corresponding properties:
246
- self._inputs = None
247
- self._outputs = None
248
- self._resources = None
249
- self._input_files = None
250
- self._output_files = None
251
- self._ss_start_obj = None
252
- self._ss_end_obj = None
253
- self._ss_diff_obj = None
165
+ self._inputs: ElementInputs | None = None
166
+ self._outputs: ElementOutputs | None = None
167
+ self._resources: ElementResources | None = None
168
+ self._input_files: ElementInputFiles | None = None
169
+ self._output_files: ElementOutputFiles | None = None
170
+ self._ss_start_obj: JSONLikeDirSnapShot | None = None
171
+ self._ss_end_obj: JSONLikeDirSnapShot | None = None
172
+ self._ss_diff_obj: DirectorySnapshotDiff | None = None
254
173
 
255
- def __repr__(self):
174
+ def __repr__(self) -> str:
256
175
  return (
257
176
  f"{self.__class__.__name__}("
258
177
  f"id={self.id_!r}, index={self.index!r}, "
@@ -274,110 +193,110 @@ class ElementActionRun:
274
193
  return self._is_pending
275
194
 
276
195
  @property
277
- def element_action(self):
196
+ def element_action(self) -> ElementAction:
278
197
  """
279
198
  The particular element action that this is a run of.
280
199
  """
281
200
  return self._element_action
282
201
 
283
202
  @property
284
- def index(self):
203
+ def index(self) -> int:
285
204
  """Run index."""
286
205
  return self._index
287
206
 
288
207
  @property
289
- def action(self):
208
+ def action(self) -> Action:
290
209
  """
291
210
  The action this is a run of.
292
211
  """
293
212
  return self.element_action.action
294
213
 
295
214
  @property
296
- def element_iteration(self):
215
+ def element_iteration(self) -> ElementIteration:
297
216
  """
298
217
  The iteration information of this run.
299
218
  """
300
219
  return self.element_action.element_iteration
301
220
 
302
221
  @property
303
- def element(self):
222
+ def element(self) -> Element:
304
223
  """
305
224
  The element this is a run of.
306
225
  """
307
226
  return self.element_iteration.element
308
227
 
309
228
  @property
310
- def workflow(self):
229
+ def workflow(self) -> Workflow:
311
230
  """
312
231
  The workflow this is a run of.
313
232
  """
314
233
  return self.element_iteration.workflow
315
234
 
316
235
  @property
317
- def data_idx(self):
236
+ def data_idx(self) -> DataIndex:
318
237
  """
319
238
  Used for looking up input data to the EAR.
320
239
  """
321
240
  return self._data_idx
322
241
 
323
242
  @property
324
- def commands_idx(self):
243
+ def commands_idx(self) -> Sequence[int]:
325
244
  """
326
245
  Indices of commands to apply.
327
246
  """
328
247
  return self._commands_idx
329
248
 
330
249
  @property
331
- def metadata(self):
250
+ def metadata(self) -> Mapping[str, Any]:
332
251
  """
333
252
  Metadata about the EAR.
334
253
  """
335
254
  return self._metadata
336
255
 
337
256
  @property
338
- def run_hostname(self):
257
+ def run_hostname(self) -> str | None:
339
258
  """
340
259
  Where to run the EAR, if known/specified.
341
260
  """
342
261
  return self._run_hostname
343
262
 
344
263
  @property
345
- def start_time(self):
264
+ def start_time(self) -> datetime | None:
346
265
  """
347
266
  When the EAR started.
348
267
  """
349
268
  return self._start_time
350
269
 
351
270
  @property
352
- def end_time(self):
271
+ def end_time(self) -> datetime | None:
353
272
  """
354
273
  When the EAR finished.
355
274
  """
356
275
  return self._end_time
357
276
 
358
277
  @property
359
- def submission_idx(self):
278
+ def submission_idx(self) -> int | None:
360
279
  """
361
280
  What actual submission index was this?
362
281
  """
363
282
  return self._submission_idx
364
283
 
365
284
  @property
366
- def success(self):
285
+ def success(self) -> bool | None:
367
286
  """
368
287
  Did the EAR succeed?
369
288
  """
370
289
  return self._success
371
290
 
372
291
  @property
373
- def skip(self):
292
+ def skip(self) -> bool:
374
293
  """
375
294
  Was the EAR skipped?
376
295
  """
377
296
  return self._skip
378
297
 
379
298
  @property
380
- def snapshot_start(self):
299
+ def snapshot_start(self) -> JSONLikeDirSnapShot | None:
381
300
  """
382
301
  The snapshot of the data directory at the start of the run.
383
302
  """
@@ -389,7 +308,7 @@ class ElementActionRun:
389
308
  return self._ss_start_obj
390
309
 
391
310
  @property
392
- def snapshot_end(self):
311
+ def snapshot_end(self) -> JSONLikeDirSnapShot | None:
393
312
  """
394
313
  The snapshot of the data directory at the end of the run.
395
314
  """
@@ -398,32 +317,34 @@ class ElementActionRun:
398
317
  return self._ss_end_obj
399
318
 
400
319
  @property
401
- def dir_diff(self) -> DirectorySnapshotDiff:
320
+ def dir_diff(self) -> DirectorySnapshotDiff | None:
402
321
  """
403
322
  The changes to the EAR working directory due to the execution of this EAR.
404
323
  """
405
- if self._ss_diff_obj is None and self.snapshot_end:
406
- self._ss_diff_obj = DirectorySnapshotDiff(
407
- self.snapshot_start, self.snapshot_end
408
- )
324
+ if (
325
+ not self._ss_diff_obj
326
+ and (ss := self.snapshot_start)
327
+ and (se := self.snapshot_end)
328
+ ):
329
+ self._ss_diff_obj = DirectorySnapshotDiff(ss, se)
409
330
  return self._ss_diff_obj
410
331
 
411
332
  @property
412
- def exit_code(self):
333
+ def exit_code(self) -> int | None:
413
334
  """
414
335
  The exit code of the underlying program run by the EAR, if known.
415
336
  """
416
337
  return self._exit_code
417
338
 
418
339
  @property
419
- def task(self):
340
+ def task(self) -> WorkflowTask:
420
341
  """
421
342
  The task that this EAR is part of the implementation of.
422
343
  """
423
344
  return self.element_action.task
424
345
 
425
346
  @property
426
- def status(self):
347
+ def status(self) -> EARStatus:
427
348
  """
428
349
  The state of this EAR.
429
350
  """
@@ -445,18 +366,16 @@ class ElementActionRun:
445
366
  elif self.submission_idx is not None:
446
367
  wk_sub_stat = self.workflow.submissions[self.submission_idx].status
447
368
 
448
- if wk_sub_stat.name == "PENDING":
369
+ if wk_sub_stat == SubmissionStatus.PENDING:
449
370
  return EARStatus.prepared
450
-
451
- elif wk_sub_stat.name == "SUBMITTED":
371
+ elif wk_sub_stat == SubmissionStatus.SUBMITTED:
452
372
  return EARStatus.submitted
453
-
454
373
  else:
455
374
  RuntimeError(f"Workflow submission status not understood: {wk_sub_stat}.")
456
375
 
457
376
  return EARStatus.pending
458
377
 
459
- def get_parameter_names(self, prefix: str) -> List[str]:
378
+ def get_parameter_names(self, prefix: str) -> Sequence[str]:
460
379
  """Get parameter types associated with a given prefix.
461
380
 
462
381
  For inputs, labels are ignored. See `Action.get_parameter_names` for more
@@ -466,11 +385,10 @@ class ElementActionRun:
466
385
  ----------
467
386
  prefix
468
387
  One of "inputs", "outputs", "input_files", "output_files".
469
-
470
388
  """
471
389
  return self.action.get_parameter_names(prefix)
472
390
 
473
- def get_data_idx(self, path: str = None):
391
+ def get_data_idx(self, path: str | None = None) -> DataIndex:
474
392
  """
475
393
  Get the data index of a value in the most recent iteration.
476
394
 
@@ -485,14 +403,37 @@ class ElementActionRun:
485
403
  run_idx=self.index,
486
404
  )
487
405
 
406
+ @overload
407
+ def get_parameter_sources(
408
+ self,
409
+ *,
410
+ path: str | None = None,
411
+ typ: str | None = None,
412
+ as_strings: Literal[False] = False,
413
+ use_task_index: bool = False,
414
+ ) -> Mapping[str, ParamSource | list[ParamSource]]:
415
+ ...
416
+
417
+ @overload
418
+ def get_parameter_sources(
419
+ self,
420
+ *,
421
+ path: str | None = None,
422
+ typ: str | None = None,
423
+ as_strings: Literal[True],
424
+ use_task_index: bool = False,
425
+ ) -> Mapping[str, str]:
426
+ ...
427
+
488
428
  @TimeIt.decorator
489
429
  def get_parameter_sources(
490
430
  self,
491
- path: str = None,
492
- typ: str = None,
431
+ *,
432
+ path: str | None = None,
433
+ typ: str | None = None,
493
434
  as_strings: bool = False,
494
435
  use_task_index: bool = False,
495
- ):
436
+ ) -> Mapping[str, str] | Mapping[str, ParamSource | list[ParamSource]]:
496
437
  """
497
438
  Get the source or sources of a parameter in the most recent iteration.
498
439
 
@@ -507,22 +448,31 @@ class ElementActionRun:
507
448
  use_task_index:
508
449
  Whether to use the task index.
509
450
  """
451
+ if as_strings:
452
+ return self.element_iteration.get_parameter_sources(
453
+ path,
454
+ action_idx=self.element_action.action_idx,
455
+ run_idx=self.index,
456
+ typ=typ,
457
+ as_strings=True,
458
+ use_task_index=use_task_index,
459
+ )
510
460
  return self.element_iteration.get_parameter_sources(
511
461
  path,
512
462
  action_idx=self.element_action.action_idx,
513
463
  run_idx=self.index,
514
464
  typ=typ,
515
- as_strings=as_strings,
465
+ as_strings=False,
516
466
  use_task_index=use_task_index,
517
467
  )
518
468
 
519
469
  def get(
520
470
  self,
521
- path: str = None,
522
- default: Any = None,
471
+ path: str | None = None,
472
+ default: Any | None = None,
523
473
  raise_on_missing: bool = False,
524
474
  raise_on_unset: bool = False,
525
- ):
475
+ ) -> Any:
526
476
  """
527
477
  Get a value (parameter, input, output, etc.) from the most recent iteration.
528
478
 
@@ -548,153 +498,169 @@ class ElementActionRun:
548
498
  raise_on_unset=raise_on_unset,
549
499
  )
550
500
 
551
- @TimeIt.decorator
552
- def get_EAR_dependencies(self, as_objects=False):
553
- """Get EARs that this EAR depends on."""
501
+ @overload
502
+ def get_EAR_dependencies(self, as_objects: Literal[False] = False) -> set[int]:
503
+ ...
554
504
 
555
- out = []
505
+ @overload
506
+ def get_EAR_dependencies(self, as_objects: Literal[True]) -> list[ElementActionRun]:
507
+ ...
508
+
509
+ @TimeIt.decorator
510
+ def get_EAR_dependencies(self, as_objects=False) -> list[ElementActionRun] | set[int]:
511
+ """Get EARs that this EAR depends on, or just their IDs."""
512
+ out: set[int] = set()
556
513
  for src in self.get_parameter_sources(typ="EAR_output").values():
557
- if not isinstance(src, list):
558
- src = [src]
559
- for src_i in src:
560
- EAR_ID_i = src_i["EAR_ID"]
514
+ for src_i in src if isinstance(src, list) else [src]:
515
+ EAR_ID_i: int = src_i["EAR_ID"]
561
516
  if EAR_ID_i != self.id_:
562
517
  # don't record a self dependency!
563
- out.append(EAR_ID_i)
564
-
565
- out = sorted(out)
518
+ out.add(EAR_ID_i)
566
519
 
567
520
  if as_objects:
568
- out = self.workflow.get_EARs_from_IDs(out)
569
-
521
+ return self.workflow.get_EARs_from_IDs(sorted(out))
570
522
  return out
571
523
 
572
- def get_input_dependencies(self):
524
+ def get_input_dependencies(self) -> Mapping[str, ParamSource]:
573
525
  """Get information about locally defined input, sequence, and schema-default
574
526
  values that this EAR depends on. Note this does not get values from this EAR's
575
527
  task/schema, because the aim of this method is to help determine which upstream
576
528
  tasks this EAR depends on."""
577
529
 
578
- out = {}
579
- for k, v in self.get_parameter_sources().items():
580
- if not isinstance(v, list):
581
- v = [v]
582
- for v_i in v:
583
- if (
584
- v_i["type"] in ["local_input", "default_input"]
585
- and v_i["task_insert_ID"] != self.task.insert_ID
586
- ):
587
- out[k] = v_i
530
+ wanted_types = ("local_input", "default_input")
531
+ return {
532
+ k: v_i
533
+ for k, v in self.get_parameter_sources().items()
534
+ for v_i in (v if isinstance(v, list) else [v])
535
+ if (
536
+ v_i["type"] in wanted_types
537
+ and v_i["task_insert_ID"] != self.task.insert_ID
538
+ )
539
+ }
540
+
541
+ @overload
542
+ def get_dependent_EARs(self, as_objects: Literal[False] = False) -> set[int]:
543
+ ...
588
544
 
589
- return out
545
+ @overload
546
+ def get_dependent_EARs(self, as_objects: Literal[True]) -> list[ElementActionRun]:
547
+ ...
590
548
 
591
549
  def get_dependent_EARs(
592
- self, as_objects=False
593
- ) -> List[Union[int, app.ElementActionRun]]:
550
+ self, as_objects: bool = False
551
+ ) -> list[ElementActionRun] | set[int]:
594
552
  """Get downstream EARs that depend on this EAR."""
595
- deps = []
596
- for task in self.workflow.tasks[self.task.index :]:
597
- for elem in task.elements[:]:
598
- for iter_ in elem.iterations:
599
- for run in iter_.action_runs:
600
- for dep_EAR_i in run.get_EAR_dependencies(as_objects=True):
601
- # does dep_EAR_i belong to self?
602
- if dep_EAR_i.id_ == self._id:
603
- deps.append(run.id_)
604
- deps = sorted(deps)
553
+ deps = {
554
+ run.id_
555
+ for task in self.workflow.tasks[self.task.index :]
556
+ for elem in task.elements[:]
557
+ for iter_ in elem.iterations
558
+ for run in iter_.action_runs
559
+ # does EAR dependency belong to self?
560
+ if self._id in run.get_EAR_dependencies()
561
+ }
605
562
  if as_objects:
606
- deps = self.workflow.get_EARs_from_IDs(deps)
607
-
563
+ return self.workflow.get_EARs_from_IDs(sorted(deps))
608
564
  return deps
609
565
 
610
566
  @property
611
- def inputs(self):
567
+ def inputs(self) -> ElementInputs:
612
568
  """
613
569
  The inputs to this EAR.
614
570
  """
615
571
  if not self._inputs:
616
- self._inputs = self.app.ElementInputs(element_action_run=self)
572
+ self._inputs = self._app.ElementInputs(element_action_run=self)
617
573
  return self._inputs
618
574
 
619
575
  @property
620
- def outputs(self):
576
+ def outputs(self) -> ElementOutputs:
621
577
  """
622
578
  The outputs from this EAR.
623
579
  """
624
580
  if not self._outputs:
625
- self._outputs = self.app.ElementOutputs(element_action_run=self)
581
+ self._outputs = self._app.ElementOutputs(element_action_run=self)
626
582
  return self._outputs
627
583
 
628
584
  @property
629
585
  @TimeIt.decorator
630
- def resources(self):
586
+ def resources(self) -> ElementResources:
631
587
  """
632
588
  The resources to use with (or used by) this EAR.
633
589
  """
634
590
  if not self._resources:
635
- self._resources = self.app.ElementResources(**self.get_resources())
591
+ self._resources = self.__get_resources_obj()
636
592
  return self._resources
637
593
 
638
594
  @property
639
- def input_files(self):
595
+ def input_files(self) -> ElementInputFiles:
640
596
  """
641
597
  The input files to the controlled program.
642
598
  """
643
599
  if not self._input_files:
644
- self._input_files = self.app.ElementInputFiles(element_action_run=self)
600
+ self._input_files = self._app.ElementInputFiles(element_action_run=self)
645
601
  return self._input_files
646
602
 
647
603
  @property
648
- def output_files(self):
604
+ def output_files(self) -> ElementOutputFiles:
649
605
  """
650
606
  The output files from the controlled program.
651
607
  """
652
608
  if not self._output_files:
653
- self._output_files = self.app.ElementOutputFiles(element_action_run=self)
609
+ self._output_files = self._app.ElementOutputFiles(element_action_run=self)
654
610
  return self._output_files
655
611
 
656
612
  @property
657
- def env_spec(self) -> Dict[str, Any]:
613
+ def env_spec(self) -> Mapping[str, Any]:
658
614
  """
659
615
  Environment details.
660
616
  """
661
- return self.resources.environments[self.action.get_environment_name()]
617
+ if (envs := self.resources.environments) is None:
618
+ return {}
619
+ return envs[self.action.get_environment_name()]
662
620
 
663
621
  @TimeIt.decorator
664
- def get_resources(self):
622
+ def get_resources(self) -> Mapping[str, Any]:
665
623
  """Resolve specific resources for this EAR, considering all applicable scopes and
666
624
  template-level resources."""
667
625
  return self.element_iteration.get_resources(self.action)
668
626
 
669
- def get_environment_spec(self) -> str:
627
+ @TimeIt.decorator
628
+ def __get_resources_obj(self) -> ElementResources:
629
+ """Resolve specific resources for this EAR, considering all applicable scopes and
630
+ template-level resources."""
631
+ return self.element_iteration.get_resources_obj(self.action)
632
+
633
+ def get_environment_spec(self) -> Mapping[str, Any]:
670
634
  """
671
635
  What environment to run in?
672
636
  """
673
637
  return self.action.get_environment_spec()
674
638
 
675
- def get_environment(self) -> app.Environment:
639
+ def get_environment(self) -> Environment:
676
640
  """
677
641
  What environment to run in?
678
642
  """
679
643
  return self.action.get_environment()
680
644
 
681
- def get_all_previous_iteration_runs(self, include_self: bool = True):
645
+ def get_all_previous_iteration_runs(
646
+ self, include_self: bool = True
647
+ ) -> list[ElementActionRun]:
682
648
  """Get a list of run over all iterations that correspond to this run, optionally
683
649
  including this run."""
684
650
  self_iter = self.element_iteration
685
651
  self_elem = self_iter.element
686
652
  self_act_idx = self.element_action.action_idx
687
- max_idx = self_iter.index + 1 if include_self else self_iter.index
688
- all_runs = []
689
- for iter_i in self_elem.iterations[:max_idx]:
690
- all_runs.append(iter_i.actions[self_act_idx].runs[-1])
691
- return all_runs
653
+ max_idx = self_iter.index + (1 if include_self else 0)
654
+ return [
655
+ iter_i.actions[self_act_idx].runs[-1]
656
+ for iter_i in self_elem.iterations[:max_idx]
657
+ ]
692
658
 
693
659
  def get_input_values(
694
660
  self,
695
- inputs: Optional[Union[List[str], Dict[str, Dict]]] = None,
661
+ inputs: Sequence[str] | Mapping[str, Mapping[str, Any]] | None = None,
696
662
  label_dict: bool = True,
697
- ) -> Dict[str, Any]:
663
+ ) -> Mapping[str, Mapping[str, Any]]:
698
664
  """Get a dict of (optionally a subset of) inputs values for this run.
699
665
 
700
666
  Parameters
@@ -714,75 +680,80 @@ class ElementActionRun:
714
680
  if not inputs:
715
681
  inputs = self.get_parameter_names("inputs")
716
682
 
717
- out = {}
683
+ out: dict[str, dict[str, Any]] = {}
718
684
  for inp_name in inputs:
719
- path_i, label_i = split_param_label(inp_name)
720
-
721
- try:
722
- all_iters = inputs[inp_name]["all_iterations"]
723
- except (TypeError, KeyError):
724
- all_iters = False
725
-
726
- if all_iters:
727
- all_runs = self.get_all_previous_iteration_runs(include_self=True)
685
+ if self.__all_iters(inputs, inp_name):
728
686
  val_i = {
729
687
  f"iteration_{run_i.element_iteration.index}": {
730
688
  "loop_idx": run_i.element_iteration.loop_idx,
731
689
  "value": run_i.get(f"inputs.{inp_name}"),
732
690
  }
733
- for run_i in all_runs
691
+ for run_i in self.get_all_previous_iteration_runs(include_self=True)
734
692
  }
735
693
  else:
736
694
  val_i = self.get(f"inputs.{inp_name}")
737
695
 
738
- key = inp_name
739
- if label_dict and label_i:
740
- key = path_i # exclude label from key
741
-
742
- if "." in key:
743
- # for sub-parameters, take only the final part as the dict key:
744
- key = key.split(".")[-1]
745
-
746
- if label_dict and label_i:
747
- if key not in out:
748
- out[key] = {}
749
- out[key][label_i] = val_i
696
+ key, label_i = self.__split_input_name(inp_name, label_dict)
697
+ if label_i:
698
+ out.setdefault(key, {})[label_i] = val_i
750
699
  else:
751
700
  out[key] = val_i
752
701
 
753
702
  if self.action.script_pass_env_spec:
754
- out["env_spec"] = self.env_spec
703
+ out["env_spec"] = cast("Any", self.env_spec)
755
704
 
756
705
  return out
757
706
 
758
- def get_input_values_direct(self, label_dict: bool = True):
707
+ @staticmethod
708
+ def __all_iters(
709
+ inputs: Sequence[str] | Mapping[str, Mapping[str, Any]], inp_name: str
710
+ ) -> bool:
711
+ try:
712
+ return isinstance(inputs, Mapping) and bool(
713
+ inputs[inp_name]["all_iterations"]
714
+ )
715
+ except (TypeError, KeyError):
716
+ return False
717
+
718
+ @staticmethod
719
+ def __split_input_name(inp_name: str, label_dict: bool) -> tuple[str, str | None]:
720
+ key = inp_name
721
+ path, label = split_param_label(key)
722
+ if label_dict and path:
723
+ key = path # exclude label from key
724
+ # for sub-parameters, take only the final part as the dict key:
725
+ return key.split(".")[-1], (label if label_dict else None)
726
+
727
+ def get_input_values_direct(
728
+ self, label_dict: bool = True
729
+ ) -> Mapping[str, Mapping[str, Any]]:
759
730
  """Get a dict of input values that are to be passed directly to a Python script
760
731
  function."""
761
732
  inputs = self.action.script_data_in_grouped.get("direct", {})
762
733
  return self.get_input_values(inputs=inputs, label_dict=label_dict)
763
734
 
764
- def get_IFG_input_values(self) -> Dict[str, Any]:
735
+ def get_IFG_input_values(self) -> Mapping[str, Any]:
765
736
  """
766
737
  Get a dict of input values that are to be passed via an input file generator.
767
738
  """
768
739
  if not self.action._from_expand:
769
740
  raise RuntimeError(
770
- f"Cannot get input file generator inputs from this EAR because the "
771
- f"associated action is not expanded, meaning multiple IFGs might exists."
741
+ "Cannot get input file generator inputs from this EAR because the "
742
+ "associated action is not expanded, meaning multiple IFGs might exists."
772
743
  )
773
- input_types = [i.typ for i in self.action.input_file_generators[0].inputs]
774
- inputs = {}
775
- for i in self.inputs:
776
- typ = i.path[len("inputs.") :]
777
- if typ in input_types:
778
- inputs[typ] = i.value
744
+ input_types = {param.typ for param in self.action.input_file_generators[0].inputs}
745
+ inputs: dict[str, Any] = {}
746
+ for inp in self.inputs:
747
+ assert isinstance(inp, self._app.ElementParameter)
748
+ if (typ := inp.path[len("inputs.") :]) in input_types:
749
+ inputs[typ] = inp.value
779
750
 
780
751
  if self.action.script_pass_env_spec:
781
752
  inputs["env_spec"] = self.env_spec
782
753
 
783
754
  return inputs
784
755
 
785
- def get_OFP_output_files(self) -> Dict[str, Union[str, List[str]]]:
756
+ def get_OFP_output_files(self) -> Mapping[str, Path]:
786
757
  """
787
758
  Get a dict of output files that are going to be parsed to generate one or more
788
759
  outputs.
@@ -790,118 +761,142 @@ class ElementActionRun:
790
761
  # TODO: can this return multiple files for a given FileSpec?
791
762
  if not self.action._from_expand:
792
763
  raise RuntimeError(
793
- f"Cannot get output file parser files from this from EAR because the "
794
- f"associated action is not expanded, meaning multiple OFPs might exist."
764
+ "Cannot get output file parser files from this from EAR because the "
765
+ "associated action is not expanded, meaning multiple OFPs might exist."
795
766
  )
796
- out_files = {}
797
- for file_spec in self.action.output_file_parsers[0].output_files:
798
- out_files[file_spec.label] = Path(file_spec.name.value())
799
- return out_files
767
+ return {
768
+ file_spec.label: Path(cast("str", file_spec.name.value()))
769
+ for file_spec in self.action.output_file_parsers[0].output_files
770
+ }
800
771
 
801
- def get_OFP_inputs(self) -> Dict[str, Union[str, List[str]]]:
772
+ def get_OFP_inputs(self) -> Mapping[str, str | list[str] | Mapping[str, Any]]:
802
773
  """
803
774
  Get a dict of input values that are to be passed to output file parsers.
804
775
  """
805
776
  if not self.action._from_expand:
806
777
  raise RuntimeError(
807
- f"Cannot get output file parser inputs from this from EAR because the "
808
- f"associated action is not expanded, meaning multiple OFPs might exist."
778
+ "Cannot get output file parser inputs from this from EAR because the "
779
+ "associated action is not expanded, meaning multiple OFPs might exist."
809
780
  )
810
- inputs = {}
811
- for inp_typ in self.action.output_file_parsers[0].inputs or []:
812
- inputs[inp_typ] = self.get(f"inputs.{inp_typ}")
781
+ inputs: dict[str, str | list[str] | Mapping[str, Any]] = {
782
+ inp_typ: self.get(f"inputs.{inp_typ}")
783
+ for inp_typ in self.action.output_file_parsers[0].inputs or ()
784
+ }
813
785
 
814
786
  if self.action.script_pass_env_spec:
815
787
  inputs["env_spec"] = self.env_spec
816
788
 
817
789
  return inputs
818
790
 
819
- def get_OFP_outputs(self) -> Dict[str, Union[str, List[str]]]:
791
+ def get_OFP_outputs(self) -> Mapping[str, str | list[str]]:
820
792
  """
821
793
  Get the outputs obtained by parsing an output file.
822
794
  """
823
795
  if not self.action._from_expand:
824
796
  raise RuntimeError(
825
- f"Cannot get output file parser outputs from this from EAR because the "
826
- f"associated action is not expanded, meaning multiple OFPs might exist."
797
+ "Cannot get output file parser outputs from this from EAR because the "
798
+ "associated action is not expanded, meaning multiple OFPs might exist."
827
799
  )
828
- outputs = {}
829
- for out_typ in self.action.output_file_parsers[0].outputs or []:
830
- outputs[out_typ] = self.get(f"outputs.{out_typ}")
831
- return outputs
800
+ return {
801
+ out_typ: self.get(f"outputs.{out_typ}")
802
+ for out_typ in self.action.output_file_parsers[0].outputs or ()
803
+ }
832
804
 
833
- def write_source(self, js_idx: int, js_act_idx: int):
805
+ def write_source(self, js_idx: int, js_act_idx: int) -> None:
834
806
  """
835
807
  Write values to files in standard formats.
836
808
  """
837
- import h5py
838
-
839
809
  for fmt, ins in self.action.script_data_in_grouped.items():
840
- if fmt == "json":
841
- in_vals = self.get_input_values(inputs=ins, label_dict=False)
842
- dump_path = self.action.get_param_dump_file_path_JSON(js_idx, js_act_idx)
843
- in_vals_processed = {}
844
- for k, v in in_vals.items():
845
- try:
846
- v = v.prepare_JSON_dump()
847
- except (AttributeError, NotImplementedError):
848
- pass
849
- in_vals_processed[k] = v
850
-
851
- with dump_path.open("wt") as fp:
852
- json.dump(in_vals_processed, fp)
853
-
854
- elif fmt == "hdf5":
855
- in_vals = self.get_input_values(inputs=ins, label_dict=False)
856
- dump_path = self.action.get_param_dump_file_path_HDF5(js_idx, js_act_idx)
857
- with h5py.File(dump_path, mode="w") as f:
858
- for k, v in in_vals.items():
859
- grp_k = f.create_group(k)
860
- v.dump_to_HDF5_group(grp_k)
810
+ in_vals = self.get_input_values(inputs=ins, label_dict=False)
811
+ if writer := self.__source_writer_map.get(fmt):
812
+ writer(self, in_vals, js_idx, js_act_idx)
861
813
 
862
814
  # write the script if it is specified as a app data script, otherwise we assume
863
815
  # the script already exists in the working directory:
864
- snip_path = self.action.get_snippet_script_path(self.action.script, self.env_spec)
865
- if snip_path:
866
- script_name = snip_path.name
867
- source_str = self.action.compose_source(snip_path)
868
- with Path(script_name).open("wt", newline="\n") as fp:
869
- fp.write(source_str)
816
+ if snip_path := self.action.get_snippet_script_path(
817
+ self.action.script, self.env_spec
818
+ ):
819
+ with Path(snip_path.name).open("wt", newline="\n") as fp:
820
+ fp.write(self.action.compose_source(snip_path))
821
+
822
+ def __write_json_inputs(
823
+ self, in_vals: Mapping[str, ParameterValue], js_idx: int, js_act_idx: int
824
+ ):
825
+ in_vals_processed: dict[str, Any] = {}
826
+ for k, v in in_vals.items():
827
+ try:
828
+ in_vals_processed[k] = (
829
+ v.prepare_JSON_dump() if isinstance(v, ParameterValue) else v
830
+ )
831
+ except (AttributeError, NotImplementedError):
832
+ in_vals_processed[k] = v
833
+
834
+ with self.action.get_param_dump_file_path_JSON(js_idx, js_act_idx).open(
835
+ "wt"
836
+ ) as fp:
837
+ json.dump(in_vals_processed, fp)
838
+
839
+ def __write_hdf5_inputs(
840
+ self, in_vals: Mapping[str, ParameterValue], js_idx: int, js_act_idx: int
841
+ ):
842
+ import h5py # type: ignore
843
+
844
+ with h5py.File(
845
+ self.action.get_param_dump_file_path_HDF5(js_idx, js_act_idx), mode="w"
846
+ ) as h5file:
847
+ for k, v in in_vals.items():
848
+ v.dump_to_HDF5_group(h5file.create_group(k))
849
+
850
+ __source_writer_map: ClassVar[dict[str, Callable[..., None]]] = {
851
+ "json": __write_json_inputs,
852
+ "hdf5": __write_hdf5_inputs,
853
+ }
854
+
855
+ def __output_index(self, param_name: str) -> int:
856
+ return cast("int", self.data_idx[f"outputs.{param_name}"])
870
857
 
871
858
  def _param_save(self, js_idx: int, js_act_idx: int):
872
859
  """Save script-generated parameters that are stored within the supported script
873
860
  data output formats (HDF5, JSON, etc)."""
874
- import h5py
861
+ import h5py # type: ignore
875
862
 
863
+ parameters = self._app.parameters
876
864
  for fmt in self.action.script_data_out_grouped:
877
865
  if fmt == "json":
878
- load_path = self.action.get_param_load_file_path_JSON(js_idx, js_act_idx)
879
- with load_path.open(mode="rt") as f:
880
- file_data = json.load(f)
866
+ with self.action.get_param_load_file_path_JSON(js_idx, js_act_idx).open(
867
+ mode="rt"
868
+ ) as f:
869
+ file_data: dict[str, Any] = json.load(f)
881
870
  for param_name, param_dat in file_data.items():
882
- param_id = self.data_idx[f"outputs.{param_name}"]
883
- param_cls = self.app.parameters.get(param_name)._value_class
884
- try:
871
+ param_id = self.__output_index(param_name)
872
+ if param_cls := parameters.get(param_name)._force_value_class():
885
873
  param_cls.save_from_JSON(param_dat, param_id, self.workflow)
886
- continue
887
- except (AttributeError, NotImplementedError):
888
- pass
889
- # try to save as a primitive:
890
- self.workflow.set_parameter_value(
891
- param_id=param_id, value=param_dat
892
- )
874
+ else:
875
+ # try to save as a primitive:
876
+ self.workflow.set_parameter_value(
877
+ param_id=param_id, value=param_dat
878
+ )
893
879
 
894
880
  elif fmt == "hdf5":
895
- load_path = self.action.get_param_load_file_path_HDF5(js_idx, js_act_idx)
896
- with h5py.File(load_path, mode="r") as f:
897
- for param_name, h5_grp in f.items():
898
- param_id = self.data_idx[f"outputs.{param_name}"]
899
- param_cls = self.app.parameters.get(param_name)._value_class
900
- param_cls.save_from_HDF5_group(h5_grp, param_id, self.workflow)
881
+ with h5py.File(
882
+ self.action.get_param_load_file_path_HDF5(js_idx, js_act_idx),
883
+ mode="r",
884
+ ) as h5file:
885
+ for param_name, h5_grp in h5file.items():
886
+ if param_cls := parameters.get(param_name)._force_value_class():
887
+ param_cls.save_from_HDF5_group(
888
+ h5_grp, self.__output_index(param_name), self.workflow
889
+ )
890
+ else:
891
+ # Unlike with JSON, we've no fallback so we warn
892
+ self._app.logger.warning(
893
+ "parameter %s could not be saved; serializer not found",
894
+ param_name,
895
+ )
901
896
 
902
897
  def compose_commands(
903
- self, jobscript: app.Jobscript, JS_action_idx: int
904
- ) -> Tuple[str, List[str], List[int]]:
898
+ self, jobscript: Jobscript, JS_action_idx: int
899
+ ) -> tuple[str, Mapping[int, Sequence[tuple[str, ...]]]]:
905
900
  """
906
901
  Write the EAR's enactment to disk in preparation for submission.
907
902
 
@@ -909,12 +904,13 @@ class ElementActionRun:
909
904
  -------
910
905
  commands:
911
906
  List of argument words for the command that enacts the EAR.
907
+ Converted to a string.
912
908
  shell_vars:
913
909
  Dict whose keys are command indices, and whose values are lists of tuples,
914
910
  where each tuple contains: (parameter name, shell variable name,
915
911
  "stdout"/"stderr").
916
912
  """
917
- self.app.persistence_logger.debug("EAR.compose_commands")
913
+ self._app.persistence_logger.debug("EAR.compose_commands")
918
914
  env_spec = self.env_spec
919
915
 
920
916
  for ifg in self.action.input_file_generators:
@@ -930,27 +926,23 @@ class ElementActionRun:
930
926
  if self.action.script:
931
927
  self.write_source(js_idx=jobscript.index, js_act_idx=JS_action_idx)
932
928
 
933
- command_lns = []
934
- env = jobscript.submission.environments.get(**env_spec)
935
- if env.setup:
936
- command_lns += list(env.setup)
929
+ command_lns: list[str] = []
930
+ if (env := jobscript.submission.environments.get(**env_spec)).setup:
931
+ command_lns.extend(env.setup)
937
932
 
938
- shell_vars = {} # keys are cmd_idx, each value is a list of tuples
933
+ shell_vars: dict[int, list[tuple[str, ...]]] = {}
939
934
  for cmd_idx, command in enumerate(self.action.commands):
940
935
  if cmd_idx in self.commands_idx:
941
936
  # only execute commands that have no rules, or all valid rules:
942
- cmd_str, shell_vars_i = command.get_command_line(
937
+ cmd_str, shell_vars[cmd_idx] = command.get_command_line(
943
938
  EAR=self, shell=jobscript.shell, env=env
944
939
  )
945
- shell_vars[cmd_idx] = shell_vars_i
946
940
  command_lns.append(cmd_str)
947
941
 
948
- commands = "\n".join(command_lns) + "\n"
949
-
950
- return commands, shell_vars
942
+ return ("\n".join(command_lns) + "\n"), shell_vars
951
943
 
952
944
 
953
- class ElementAction:
945
+ class ElementAction(AppAware):
954
946
  """
955
947
  An abstract representation of an element's action at a particular iteration and
956
948
  the runs that enact that element iteration.
@@ -965,20 +957,23 @@ class ElementAction:
965
957
  The list of run indices.
966
958
  """
967
959
 
968
- _app_attr = "app"
969
-
970
- def __init__(self, element_iteration, action_idx, runs):
960
+ def __init__(
961
+ self,
962
+ element_iteration: ElementIteration,
963
+ action_idx: int,
964
+ runs: dict[Mapping[str, Any], Any],
965
+ ):
971
966
  self._element_iteration = element_iteration
972
967
  self._action_idx = action_idx
973
968
  self._runs = runs
974
969
 
975
970
  # assigned on first access of corresponding properties:
976
- self._run_objs = None
977
- self._inputs = None
978
- self._outputs = None
979
- self._resources = None
980
- self._input_files = None
981
- self._output_files = None
971
+ self._run_objs: list[ElementActionRun] | None = None
972
+ self._inputs: ElementInputs | None = None
973
+ self._outputs: ElementOutputs | None = None
974
+ self._resources: ElementResources | None = None
975
+ self._input_files: ElementInputFiles | None = None
976
+ self._output_files: ElementOutputFiles | None = None
982
977
 
983
978
  def __repr__(self):
984
979
  return (
@@ -990,104 +985,104 @@ class ElementAction:
990
985
  )
991
986
 
992
987
  @property
993
- def element_iteration(self):
988
+ def element_iteration(self) -> ElementIteration:
994
989
  """
995
990
  The iteration for this action.
996
991
  """
997
992
  return self._element_iteration
998
993
 
999
994
  @property
1000
- def element(self):
995
+ def element(self) -> Element:
1001
996
  """
1002
997
  The element for this action.
1003
998
  """
1004
999
  return self.element_iteration.element
1005
1000
 
1006
1001
  @property
1007
- def num_runs(self):
1002
+ def num_runs(self) -> int:
1008
1003
  """
1009
1004
  The number of runs associated with this action.
1010
1005
  """
1011
1006
  return len(self._runs)
1012
1007
 
1013
1008
  @property
1014
- def runs(self):
1009
+ def runs(self) -> list[ElementActionRun]:
1015
1010
  """
1016
1011
  The EARs that this action is enacted by.
1017
1012
  """
1018
1013
  if self._run_objs is None:
1019
1014
  self._run_objs = [
1020
- self.app.ElementActionRun(
1015
+ self._app.ElementActionRun(
1021
1016
  element_action=self,
1022
1017
  index=idx,
1023
1018
  **{
1024
1019
  k: v
1025
- for k, v in i.items()
1020
+ for k, v in run_info.items()
1026
1021
  if k not in ("elem_iter_ID", "action_idx")
1027
1022
  },
1028
1023
  )
1029
- for idx, i in enumerate(self._runs)
1024
+ for idx, run_info in enumerate(self._runs)
1030
1025
  ]
1031
1026
  return self._run_objs
1032
1027
 
1033
1028
  @property
1034
- def task(self):
1029
+ def task(self) -> WorkflowTask:
1035
1030
  """
1036
1031
  The task that this action is an instance of.
1037
1032
  """
1038
1033
  return self.element_iteration.task
1039
1034
 
1040
1035
  @property
1041
- def action_idx(self):
1036
+ def action_idx(self) -> int:
1042
1037
  """
1043
1038
  The index of the action.
1044
1039
  """
1045
1040
  return self._action_idx
1046
1041
 
1047
1042
  @property
1048
- def action(self):
1043
+ def action(self) -> Action:
1049
1044
  """
1050
1045
  The abstract task that this is a concrete model of.
1051
1046
  """
1052
1047
  return self.task.template.get_schema_action(self.action_idx)
1053
1048
 
1054
1049
  @property
1055
- def inputs(self):
1050
+ def inputs(self) -> ElementInputs:
1056
1051
  """
1057
1052
  The inputs to this action.
1058
1053
  """
1059
1054
  if not self._inputs:
1060
- self._inputs = self.app.ElementInputs(element_action=self)
1055
+ self._inputs = self._app.ElementInputs(element_action=self)
1061
1056
  return self._inputs
1062
1057
 
1063
1058
  @property
1064
- def outputs(self):
1059
+ def outputs(self) -> ElementOutputs:
1065
1060
  """
1066
1061
  The outputs from this action.
1067
1062
  """
1068
1063
  if not self._outputs:
1069
- self._outputs = self.app.ElementOutputs(element_action=self)
1064
+ self._outputs = self._app.ElementOutputs(element_action=self)
1070
1065
  return self._outputs
1071
1066
 
1072
1067
  @property
1073
- def input_files(self):
1068
+ def input_files(self) -> ElementInputFiles:
1074
1069
  """
1075
1070
  The input files to this action.
1076
1071
  """
1077
1072
  if not self._input_files:
1078
- self._input_files = self.app.ElementInputFiles(element_action=self)
1073
+ self._input_files = self._app.ElementInputFiles(element_action=self)
1079
1074
  return self._input_files
1080
1075
 
1081
1076
  @property
1082
- def output_files(self):
1077
+ def output_files(self) -> ElementOutputFiles:
1083
1078
  """
1084
1079
  The output files from this action.
1085
1080
  """
1086
1081
  if not self._output_files:
1087
- self._output_files = self.app.ElementOutputFiles(element_action=self)
1082
+ self._output_files = self._app.ElementOutputFiles(element_action=self)
1088
1083
  return self._output_files
1089
1084
 
1090
- def get_data_idx(self, path: str = None, run_idx: int = -1):
1085
+ def get_data_idx(self, path: str | None = None, run_idx: int = -1) -> DataIndex:
1091
1086
  """
1092
1087
  Get the data index for some path/run.
1093
1088
  """
@@ -1097,34 +1092,68 @@ class ElementAction:
1097
1092
  run_idx=run_idx,
1098
1093
  )
1099
1094
 
1095
+ @overload
1100
1096
  def get_parameter_sources(
1101
1097
  self,
1102
- path: str = None,
1098
+ path: str | None = None,
1099
+ *,
1103
1100
  run_idx: int = -1,
1104
- typ: str = None,
1101
+ typ: str | None = None,
1102
+ as_strings: Literal[False] = False,
1103
+ use_task_index: bool = False,
1104
+ ) -> Mapping[str, ParamSource | list[ParamSource]]:
1105
+ ...
1106
+
1107
+ @overload
1108
+ def get_parameter_sources(
1109
+ self,
1110
+ path: str | None = None,
1111
+ *,
1112
+ run_idx: int = -1,
1113
+ typ: str | None = None,
1114
+ as_strings: Literal[True],
1115
+ use_task_index: bool = False,
1116
+ ) -> Mapping[str, str]:
1117
+ ...
1118
+
1119
+ def get_parameter_sources(
1120
+ self,
1121
+ path: str | None = None,
1122
+ *,
1123
+ run_idx: int = -1,
1124
+ typ: str | None = None,
1105
1125
  as_strings: bool = False,
1106
1126
  use_task_index: bool = False,
1107
- ):
1127
+ ) -> Mapping[str, str] | Mapping[str, ParamSource | list[ParamSource]]:
1108
1128
  """
1109
1129
  Get information about where parameters originated.
1110
1130
  """
1131
+ if as_strings:
1132
+ return self.element_iteration.get_parameter_sources(
1133
+ path,
1134
+ action_idx=self.action_idx,
1135
+ run_idx=run_idx,
1136
+ typ=typ,
1137
+ as_strings=True,
1138
+ use_task_index=use_task_index,
1139
+ )
1111
1140
  return self.element_iteration.get_parameter_sources(
1112
1141
  path,
1113
1142
  action_idx=self.action_idx,
1114
1143
  run_idx=run_idx,
1115
1144
  typ=typ,
1116
- as_strings=as_strings,
1145
+ as_strings=False,
1117
1146
  use_task_index=use_task_index,
1118
1147
  )
1119
1148
 
1120
1149
  def get(
1121
1150
  self,
1122
- path: str = None,
1151
+ path: str | None = None,
1123
1152
  run_idx: int = -1,
1124
- default: Any = None,
1153
+ default: Any | None = None,
1125
1154
  raise_on_missing: bool = False,
1126
1155
  raise_on_unset: bool = False,
1127
- ):
1156
+ ) -> Any:
1128
1157
  """
1129
1158
  Get the value of a parameter.
1130
1159
  """
@@ -1137,7 +1166,7 @@ class ElementAction:
1137
1166
  raise_on_unset=raise_on_unset,
1138
1167
  )
1139
1168
 
1140
- def get_parameter_names(self, prefix: str) -> List[str]:
1169
+ def get_parameter_names(self, prefix: str) -> list[str]:
1141
1170
  """Get parameter types associated with a given prefix.
1142
1171
 
1143
1172
  For inputs, labels are ignored.
@@ -1152,12 +1181,13 @@ class ElementAction:
1152
1181
  return self.action.get_parameter_names(prefix)
1153
1182
 
1154
1183
 
1184
+ @final
1155
1185
  class ActionScope(JSONLike):
1156
1186
  """Class to represent the identification of a subset of task schema actions by a
1157
1187
  filtering process.
1158
1188
  """
1159
1189
 
1160
- _child_objects = (
1190
+ _child_objects: ClassVar[tuple[ChildObjectSpec, ...]] = (
1161
1191
  ChildObjectSpec(
1162
1192
  name="typ",
1163
1193
  json_like_name="type",
@@ -1166,46 +1196,53 @@ class ActionScope(JSONLike):
1166
1196
  ),
1167
1197
  )
1168
1198
 
1169
- def __init__(self, typ: Union[app.ActionScopeType, str], **kwargs):
1199
+ __ACTION_SCOPE_RE: ClassVar[Pattern] = re.compile(r"(\w*)(?:\[(.*)\])?")
1200
+
1201
+ def __init__(self, typ: ActionScopeType | str, **kwargs):
1170
1202
  if isinstance(typ, str):
1171
- typ = getattr(self.app.ActionScopeType, typ.upper())
1203
+ #: Action scope type.
1204
+ self.typ = self._app.ActionScopeType[typ.upper()]
1205
+ else:
1206
+ self.typ = typ
1172
1207
 
1173
- #: Action scope type.
1174
- self.typ = typ
1175
1208
  #: Any provided extra keyword arguments.
1176
1209
  self.kwargs = {k: v for k, v in kwargs.items() if v is not None}
1177
1210
 
1178
- bad_keys = set(kwargs.keys()) - ACTION_SCOPE_ALLOWED_KWARGS[self.typ.name]
1179
- if bad_keys:
1211
+ if bad_keys := set(kwargs) - ACTION_SCOPE_ALLOWED_KWARGS[self.typ.name]:
1180
1212
  raise TypeError(
1181
1213
  f"The following keyword arguments are unknown for ActionScopeType "
1182
1214
  f"{self.typ.name}: {bad_keys}."
1183
1215
  )
1184
1216
 
1185
- def __repr__(self):
1217
+ def __repr__(self) -> str:
1186
1218
  kwargs_str = ""
1187
1219
  if self.kwargs:
1188
1220
  kwargs_str = ", ".join(f"{k}={v!r}" for k, v in self.kwargs.items())
1189
1221
  return f"{self.__class__.__name__}.{self.typ.name.lower()}({kwargs_str})"
1190
1222
 
1191
- def __eq__(self, other):
1223
+ def __eq__(self, other: Any) -> bool:
1192
1224
  if not isinstance(other, self.__class__):
1193
1225
  return False
1194
- if self.typ is other.typ and self.kwargs == other.kwargs:
1195
- return True
1196
- return False
1226
+ return self.typ is other.typ and self.kwargs == other.kwargs
1227
+
1228
+ class __customdict(dict):
1229
+ pass
1197
1230
 
1198
1231
  @classmethod
1199
- def _parse_from_string(cls, string):
1200
- typ_str, kwargs_str = re.search(ACTION_SCOPE_REGEX, string).groups()
1201
- kwargs = {}
1232
+ def _parse_from_string(cls, string: str) -> dict[str, str]:
1233
+ if not (match := cls.__ACTION_SCOPE_RE.search(string)):
1234
+ raise TypeError(f"unparseable ActionScope: '{string}'")
1235
+ typ_str, kwargs_str = match.groups()
1236
+ # The types of the above two variables are idiotic, but bug reports to fix it
1237
+ # get closed because "it would break existing code that makes dumb assumptions"
1238
+ kwargs: dict[str, str] = cls.__customdict({"type": cast("str", typ_str)})
1202
1239
  if kwargs_str:
1203
- for i in kwargs_str.split(","):
1204
- name, val = i.split("=")
1240
+ for pair_str in kwargs_str.split(","):
1241
+ name, val = pair_str.split("=")
1205
1242
  kwargs[name.strip()] = val.strip()
1206
- return {"type": typ_str, **kwargs}
1243
+ return kwargs
1207
1244
 
1208
- def to_string(self):
1245
+ def to_string(self) -> str:
1209
1246
  """
1210
1247
  Render this action scope as a string.
1211
1248
  """
@@ -1215,59 +1252,62 @@ class ActionScope(JSONLike):
1215
1252
  return f"{self.typ.name.lower()}{kwargs_str}"
1216
1253
 
1217
1254
  @classmethod
1218
- def from_json_like(cls, json_like, shared_data=None):
1219
- if isinstance(json_like, str):
1220
- json_like = cls._parse_from_string(json_like)
1221
- else:
1222
- typ = json_like.pop("type")
1223
- json_like = {"type": typ, **json_like.pop("kwargs", {})}
1224
- return super().from_json_like(json_like, shared_data)
1255
+ def _from_json_like(
1256
+ cls,
1257
+ json_like: Mapping[str, Any] | Sequence[Mapping[str, Any]],
1258
+ shared_data: Mapping[str, Any],
1259
+ ) -> Self:
1260
+ if not isinstance(json_like, Mapping):
1261
+ raise TypeError("only mappings are supported for becoming an ActionScope")
1262
+ if not isinstance(json_like, cls.__customdict):
1263
+ # Wasn't processed by _parse_from_string() already
1264
+ json_like = {"type": json_like["type"], **json_like.get("kwargs", {})}
1265
+ return super()._from_json_like(json_like, shared_data)
1225
1266
 
1226
1267
  @classmethod
1227
- def any(cls):
1268
+ def any(cls) -> ActionScope:
1228
1269
  """
1229
1270
  Any scope.
1230
1271
  """
1231
1272
  return cls(typ=ActionScopeType.ANY)
1232
1273
 
1233
1274
  @classmethod
1234
- def main(cls):
1275
+ def main(cls) -> ActionScope:
1235
1276
  """
1236
1277
  The main scope.
1237
1278
  """
1238
1279
  return cls(typ=ActionScopeType.MAIN)
1239
1280
 
1240
1281
  @classmethod
1241
- def processing(cls):
1282
+ def processing(cls) -> ActionScope:
1242
1283
  """
1243
1284
  The processing scope.
1244
1285
  """
1245
1286
  return cls(typ=ActionScopeType.PROCESSING)
1246
1287
 
1247
1288
  @classmethod
1248
- def input_file_generator(cls, file=None):
1289
+ def input_file_generator(cls, file: str | None = None) -> ActionScope:
1249
1290
  """
1250
1291
  The scope of an input file generator.
1251
1292
  """
1252
1293
  return cls(typ=ActionScopeType.INPUT_FILE_GENERATOR, file=file)
1253
1294
 
1254
1295
  @classmethod
1255
- def output_file_parser(cls, output=None):
1296
+ def output_file_parser(cls, output: Parameter | str | None = None) -> ActionScope:
1256
1297
  """
1257
1298
  The scope of an output file parser.
1258
1299
  """
1259
1300
  return cls(typ=ActionScopeType.OUTPUT_FILE_PARSER, output=output)
1260
1301
 
1261
1302
 
1262
- @dataclass
1303
+ @dataclass()
1304
+ @hydrate
1263
1305
  class ActionEnvironment(JSONLike):
1264
1306
  """
1265
1307
  The environment that an action is enacted within.
1266
1308
  """
1267
1309
 
1268
- _app_attr = "app"
1269
-
1270
- _child_objects = (
1310
+ _child_objects: ClassVar[tuple[ChildObjectSpec, ...]] = (
1271
1311
  ChildObjectSpec(
1272
1312
  name="scope",
1273
1313
  class_name="ActionScope",
@@ -1275,24 +1315,24 @@ class ActionEnvironment(JSONLike):
1275
1315
  )
1276
1316
 
1277
1317
  #: The environment document.
1278
- environment: Union[str, Dict[str, Any]]
1318
+ environment: Mapping[str, Any]
1279
1319
  #: The scope.
1280
- scope: Optional[app.ActionScope] = None
1281
-
1282
- def __post_init__(self):
1283
- if self.scope is None:
1284
- self.scope = self.app.ActionScope.any()
1320
+ scope: ActionScope
1285
1321
 
1286
- orig_env = copy.deepcopy(self.environment)
1287
- if isinstance(self.environment, str):
1288
- self.environment = {"name": self.environment}
1322
+ def __init__(
1323
+ self, environment: str | dict[str, Any], scope: ActionScope | None = None
1324
+ ):
1325
+ if scope is None:
1326
+ self.scope = self._app.ActionScope.any()
1327
+ else:
1328
+ self.scope = scope
1289
1329
 
1290
- if "name" not in self.environment:
1291
- raise ActionEnvironmentMissingNameError(
1292
- f"The action-environment environment specification must include a string "
1293
- f"`name` key, or be specified as string that is that name. Provided "
1294
- f"environment key was {orig_env!r}."
1295
- )
1330
+ if isinstance(environment, str):
1331
+ self.environment = {"name": environment}
1332
+ else:
1333
+ if "name" not in environment:
1334
+ raise ActionEnvironmentMissingNameError(environment)
1335
+ self.environment = copy.deepcopy(environment)
1296
1336
 
1297
1337
 
1298
1338
  class ActionRule(JSONLike):
@@ -1318,20 +1358,23 @@ class ActionRule(JSONLike):
1318
1358
  Documentation for this rule, if any.
1319
1359
  """
1320
1360
 
1321
- _child_objects = (ChildObjectSpec(name="rule", class_name="Rule"),)
1361
+ _child_objects: ClassVar[tuple[ChildObjectSpec, ...]] = (
1362
+ ChildObjectSpec(name="rule", class_name="Rule"),
1363
+ )
1322
1364
 
1323
1365
  def __init__(
1324
1366
  self,
1325
- rule: Optional[app.Rule] = None,
1326
- check_exists: Optional[str] = None,
1327
- check_missing: Optional[str] = None,
1328
- path: Optional[str] = None,
1329
- condition: Optional[Union[Dict, ConditionLike]] = None,
1330
- cast: Optional[str] = None,
1331
- doc: Optional[str] = None,
1367
+ rule: Rule | None = None,
1368
+ check_exists: str | None = None,
1369
+ check_missing: str | None = None,
1370
+ path: str | None = None,
1371
+ condition: dict[str, Any] | ConditionLike | None = None,
1372
+ cast: str | None = None,
1373
+ doc: str | None = None,
1332
1374
  ):
1333
1375
  if rule is None:
1334
- rule = app.Rule(
1376
+ #: The rule to apply.
1377
+ self.rule = self._app.Rule(
1335
1378
  check_exists=check_exists,
1336
1379
  check_missing=check_missing,
1337
1380
  path=path,
@@ -1340,30 +1383,28 @@ class ActionRule(JSONLike):
1340
1383
  doc=doc,
1341
1384
  )
1342
1385
  elif any(
1343
- i is not None
1344
- for i in (check_exists, check_missing, path, condition, cast, doc)
1386
+ arg is not None
1387
+ for arg in (check_exists, check_missing, path, condition, cast, doc)
1345
1388
  ):
1346
1389
  raise TypeError(
1347
1390
  f"{self.__class__.__name__} `rule` specified in addition to rule "
1348
1391
  f"constructor arguments."
1349
1392
  )
1393
+ else:
1394
+ self.rule = rule
1350
1395
 
1351
- #: The rule to apply.
1352
- self.rule = rule
1353
1396
  #: The action that contains this rule.
1354
- self.action = None # assigned by parent action
1397
+ self.action: Action | None = None # assigned by parent action
1355
1398
  #: The command that is guarded by this rule.
1356
- self.command = None # assigned by parent command
1399
+ self.command: Command | None = None # assigned by parent command
1357
1400
 
1358
- def __eq__(self, other):
1401
+ def __eq__(self, other: Any) -> bool:
1359
1402
  if type(other) is not self.__class__:
1360
1403
  return False
1361
- if self.rule == other.rule:
1362
- return True
1363
- return False
1404
+ return self.rule == other.rule
1364
1405
 
1365
1406
  @TimeIt.decorator
1366
- def test(self, element_iteration: app.ElementIteration) -> bool:
1407
+ def test(self, element_iteration: ElementIteration) -> bool:
1367
1408
  """
1368
1409
  Test if this rule holds for a particular iteration.
1369
1410
 
@@ -1375,28 +1416,31 @@ class ActionRule(JSONLike):
1375
1416
  return self.rule.test(element_like=element_iteration, action=self.action)
1376
1417
 
1377
1418
  @classmethod
1378
- def check_exists(cls, check_exists):
1419
+ def check_exists(cls, check_exists: str) -> ActionRule:
1379
1420
  """
1380
1421
  Make an action rule that checks if a named attribute is present.
1381
1422
 
1382
1423
  Parameter
1383
1424
  ---------
1384
- check_exists: str
1425
+ check_exists:
1385
1426
  The path to the attribute to check for.
1386
1427
  """
1387
- return cls(rule=app.Rule(check_exists=check_exists))
1428
+ return cls(rule=cls._app.Rule(check_exists=check_exists))
1388
1429
 
1389
1430
  @classmethod
1390
- def check_missing(cls, check_missing):
1431
+ def check_missing(cls, check_missing: str) -> ActionRule:
1391
1432
  """
1392
1433
  Make an action rule that checks if a named attribute is absent.
1393
1434
 
1394
1435
  Parameter
1395
1436
  ---------
1396
- check_missing: str
1437
+ check_missing:
1397
1438
  The path to the attribute to check for.
1398
1439
  """
1399
- return cls(rule=app.Rule(check_missing=check_missing))
1440
+ return cls(rule=cls._app.Rule(check_missing=check_missing))
1441
+
1442
+
1443
+ _ALL_OTHER_SYM = "*"
1400
1444
 
1401
1445
 
1402
1446
  class Action(JSONLike):
@@ -1444,8 +1488,7 @@ class Action(JSONLike):
1444
1488
  The names of files to be deleted after each step.
1445
1489
  """
1446
1490
 
1447
- _app_attr = "app"
1448
- _child_objects = (
1491
+ _child_objects: ClassVar[tuple[ChildObjectSpec, ...]] = (
1449
1492
  ChildObjectSpec(
1450
1493
  name="commands",
1451
1494
  class_name="Command",
@@ -1501,35 +1544,37 @@ class Action(JSONLike):
1501
1544
  shared_data_name="command_files",
1502
1545
  ),
1503
1546
  )
1504
- _script_data_formats = ("direct", "json", "hdf5")
1547
+ _script_data_formats: ClassVar[tuple[str, ...]] = ("direct", "json", "hdf5")
1505
1548
 
1506
1549
  def __init__(
1507
1550
  self,
1508
- environments: Optional[List[app.ActionEnvironment]] = None,
1509
- commands: Optional[List[app.Command]] = None,
1510
- script: Optional[str] = None,
1511
- script_data_in: Optional[str] = None,
1512
- script_data_out: Optional[str] = None,
1513
- script_data_files_use_opt: Optional[bool] = False,
1514
- script_exe: Optional[str] = None,
1515
- script_pass_env_spec: Optional[bool] = False,
1516
- abortable: Optional[bool] = False,
1517
- input_file_generators: Optional[List[app.InputFileGenerator]] = None,
1518
- output_file_parsers: Optional[List[app.OutputFileParser]] = None,
1519
- input_files: Optional[List[app.FileSpec]] = None,
1520
- output_files: Optional[List[app.FileSpec]] = None,
1521
- rules: Optional[List[app.ActionRule]] = None,
1522
- save_files: Optional[List[str]] = None,
1523
- clean_up: Optional[List[str]] = None,
1551
+ environments: list[ActionEnvironment] | None = None,
1552
+ commands: list[Command] | None = None,
1553
+ script: str | None = None,
1554
+ script_data_in: str | Mapping[str, str | ScriptData] | None = None,
1555
+ script_data_out: str | Mapping[str, str | ScriptData] | None = None,
1556
+ script_data_files_use_opt: bool = False,
1557
+ script_exe: str | None = None,
1558
+ script_pass_env_spec: bool = False,
1559
+ abortable: bool = False,
1560
+ input_file_generators: list[InputFileGenerator] | None = None,
1561
+ output_file_parsers: list[OutputFileParser] | None = None,
1562
+ input_files: list[FileSpec] | None = None,
1563
+ output_files: list[FileSpec] | None = None,
1564
+ rules: list[ActionRule] | None = None,
1565
+ save_files: list[FileSpec] | None = None,
1566
+ clean_up: list[str] | None = None,
1524
1567
  ):
1525
1568
  #: The commands to be run by this action.
1526
1569
  self.commands = commands or []
1527
1570
  #: The name of the Python script to run.
1528
1571
  self.script = script
1529
1572
  #: Information about data input to the script.
1530
- self.script_data_in = script_data_in
1573
+ self.script_data_in: dict[str, ScriptData] | None = None
1574
+ self._script_data_in = script_data_in
1531
1575
  #: Information about data output from the script.
1532
- self.script_data_out = script_data_out
1576
+ self.script_data_out: dict[str, ScriptData] | None = None
1577
+ self._script_data_out = script_data_out
1533
1578
  #: If True, script data input and output file paths will be passed to the script
1534
1579
  #: execution command line with an option like `--input-json` or `--output-hdf5`
1535
1580
  #: etc. If False, the file paths will be passed on their own. For Python scripts,
@@ -1544,7 +1589,7 @@ class Action(JSONLike):
1544
1589
  self.script_pass_env_spec = script_pass_env_spec
1545
1590
  #: The environments in which this action can run.
1546
1591
  self.environments = environments or [
1547
- self.app.ActionEnvironment(environment="null_env")
1592
+ self._app.ActionEnvironment(environment="null_env")
1548
1593
  ]
1549
1594
  #: Whether this action can be aborted.
1550
1595
  self.abortable = abortable
@@ -1553,9 +1598,9 @@ class Action(JSONLike):
1553
1598
  #: Any applicable output file parsers.
1554
1599
  self.output_file_parsers = output_file_parsers or []
1555
1600
  #: The input files to the action's commands.
1556
- self.input_files = self._resolve_input_files(input_files or [])
1601
+ self.input_files = self.__resolve_input_files(input_files or [])
1557
1602
  #: The output files from the action's commands.
1558
- self.output_files = self._resolve_output_files(output_files or [])
1603
+ self.output_files = self.__resolve_output_files(output_files or [])
1559
1604
  #: How to determine whether to run the action.
1560
1605
  self.rules = rules or []
1561
1606
  #: The names of files to be explicitly saved after each step.
@@ -1563,125 +1608,130 @@ class Action(JSONLike):
1563
1608
  #: The names of files to be deleted after each step.
1564
1609
  self.clean_up = clean_up or []
1565
1610
 
1566
- self._task_schema = None # assigned by parent TaskSchema
1611
+ self._task_schema: TaskSchema | None = None # assigned by parent TaskSchema
1567
1612
  self._from_expand = False # assigned on creation of new Action by `expand`
1568
1613
 
1569
1614
  self._set_parent_refs()
1570
1615
 
1571
- def process_script_data_formats(self):
1616
+ def process_script_data_formats(self) -> None:
1572
1617
  """
1573
1618
  Convert script data information into standard form.
1574
1619
  """
1575
- self.script_data_in = self._process_script_data_in(self.script_data_in)
1576
- self.script_data_out = self._process_script_data_out(self.script_data_out)
1620
+ self.script_data_in = self.__process_script_data(self._script_data_in, "inputs")
1621
+ self.script_data_out = self.__process_script_data(
1622
+ self._script_data_out, "outputs"
1623
+ )
1577
1624
 
1578
- def _process_script_data_format(
1579
- self, data_fmt: Union[str, Dict[str, Union[str, Dict[str, str]]]], prefix: str
1580
- ) -> Dict[str, str]:
1581
- if not data_fmt:
1582
- return {}
1625
+ def __process_script_data_str(
1626
+ self, data_fmt: str, param_names: Iterable[str]
1627
+ ) -> dict[str, ScriptData]:
1628
+ # include all input parameters, using specified data format
1629
+ data_fmt = data_fmt.lower()
1630
+ return {k: {"format": data_fmt} for k in param_names}
1583
1631
 
1584
- _all_other_sym = "*"
1585
- param_names = self.get_parameter_names(prefix)
1586
- if isinstance(data_fmt, str):
1587
- # include all input parameters, using specified data format
1588
- data_fmt = data_fmt.lower()
1589
- all_params = {k: {"format": data_fmt} for k in param_names}
1590
- else:
1591
- all_params = copy.copy(data_fmt)
1592
- for k, v in all_params.items():
1593
- # values might be strings, or dicts with "format" and potentially other
1594
- # kwargs:
1595
- try:
1596
- fmt = v["format"]
1597
- except TypeError:
1598
- fmt = v
1599
- kwargs = {}
1600
- else:
1601
- kwargs = {k2: v2 for k2, v2 in v.items() if k2 != "format"}
1602
- finally:
1603
- all_params[k] = {"format": fmt.lower(), **kwargs}
1604
-
1605
- if prefix == "inputs":
1606
- # expand unlabelled-multiple inputs to multiple labelled inputs:
1607
- multi_types = self.task_schema.multi_input_types
1608
- multis = {}
1609
- for k in list(all_params.keys()):
1610
- if k in multi_types:
1611
- k_fmt = all_params.pop(k)
1612
- for i in param_names:
1613
- if i.startswith(k):
1614
- multis[i] = copy.deepcopy(k_fmt)
1632
+ def __process_script_data_dict(
1633
+ self,
1634
+ data_fmt: Mapping[str, str | ScriptData],
1635
+ prefix: str,
1636
+ param_names: Iterable[str],
1637
+ ) -> dict[str, ScriptData]:
1638
+ all_params: dict[str, ScriptData] = {}
1639
+ for nm, v in data_fmt.items():
1640
+ # values might be strings, or dicts with "format" and potentially other
1641
+ # kwargs:
1642
+ if isinstance(v, dict):
1643
+ # Make sure format is first key
1644
+ v2: ScriptData = {
1645
+ "format": v["format"],
1646
+ }
1647
+ all_params[nm] = v2
1648
+ v2.update(v)
1649
+ else:
1650
+ all_params[nm] = {"format": v.lower()}
1651
+
1652
+ if prefix == "inputs":
1653
+ # expand unlabelled-multiple inputs to multiple labelled inputs:
1654
+ multi_types = set(self.task_schema.multi_input_types)
1655
+ multis: dict[str, ScriptData] = {}
1656
+ for nm in tuple(all_params):
1657
+ if nm in multi_types:
1658
+ k_fmt = all_params.pop(nm)
1659
+ for name in param_names:
1660
+ if name.startswith(nm):
1661
+ multis[name] = copy.deepcopy(k_fmt)
1662
+ if multis:
1615
1663
  all_params = {
1616
1664
  **multis,
1617
1665
  **all_params,
1618
1666
  }
1619
1667
 
1620
- if _all_other_sym in all_params:
1621
- # replace catch-all with all other input/output names:
1622
- other_fmt = all_params[_all_other_sym]
1623
- all_params = {k: v for k, v in all_params.items() if k != _all_other_sym}
1624
- other = set(param_names) - set(all_params.keys())
1625
- for i in other:
1626
- all_params[i] = copy.deepcopy(other_fmt)
1668
+ if _ALL_OTHER_SYM in all_params:
1669
+ # replace catch-all with all other input/output names:
1670
+ other_fmt = all_params[_ALL_OTHER_SYM]
1671
+ all_params = {k: v for k, v in all_params.items() if k != _ALL_OTHER_SYM}
1672
+ for name in set(param_names).difference(all_params):
1673
+ all_params[name] = copy.deepcopy(other_fmt)
1674
+ return all_params
1675
+
1676
+ def __process_script_data(
1677
+ self, data_fmt: str | Mapping[str, str | ScriptData] | None, prefix: str
1678
+ ) -> dict[str, ScriptData]:
1679
+ if not data_fmt:
1680
+ return {}
1681
+
1682
+ param_names = self.get_parameter_names(prefix)
1683
+ if isinstance(data_fmt, str):
1684
+ all_params = self.__process_script_data_str(data_fmt, param_names)
1685
+ else:
1686
+ all_params = self.__process_script_data_dict(data_fmt, prefix, param_names)
1627
1687
 
1628
1688
  # validation:
1629
1689
  allowed_keys = ("format", "all_iterations")
1630
1690
  for k, v in all_params.items():
1631
1691
  # validate parameter name (sub-parameters are allowed):
1632
1692
  if k.split(".")[0] not in param_names:
1633
- raise UnknownScriptDataParameter(
1634
- f"Script data parameter {k!r} is not a known parameter of the "
1635
- f"action. Parameters ({prefix}) are: {param_names!r}."
1636
- )
1693
+ raise UnknownScriptDataParameter(k, prefix, param_names)
1637
1694
  # validate format:
1638
1695
  if v["format"] not in self._script_data_formats:
1639
1696
  raise UnsupportedScriptDataFormat(
1640
- f"Script data format {v!r} for {prefix[:-1]} parameter {k!r} is not "
1641
- f"understood. Available script data formats are: "
1642
- f"{self._script_data_formats!r}."
1697
+ v, prefix[:-1], k, self._script_data_formats
1643
1698
  )
1644
-
1645
- for k2 in v:
1646
- if k2 not in allowed_keys:
1647
- raise UnknownScriptDataKey(
1648
- f"Script data key {k2!r} is not understood. Allowed keys are: "
1649
- f"{allowed_keys!r}."
1650
- )
1699
+ if any((bad_key := k2) for k2 in v if k2 not in allowed_keys):
1700
+ raise UnknownScriptDataKey(bad_key, allowed_keys)
1651
1701
 
1652
1702
  return all_params
1653
1703
 
1654
- def _process_script_data_in(
1655
- self, data_fmt: Union[str, Dict[str, str]]
1656
- ) -> Dict[str, str]:
1657
- return self._process_script_data_format(data_fmt, "inputs")
1658
-
1659
- def _process_script_data_out(
1660
- self, data_fmt: Union[str, Dict[str, str]]
1661
- ) -> Dict[str, str]:
1662
- return self._process_script_data_format(data_fmt, "outputs")
1663
-
1664
1704
  @property
1665
- def script_data_in_grouped(self) -> Dict[str, List[str]]:
1705
+ def script_data_in_grouped(self) -> Mapping[str, Mapping[str, Mapping[str, str]]]:
1666
1706
  """Get input parameter types by script data-in format."""
1667
- return swap_nested_dict_keys(dct=self.script_data_in, inner_key="format")
1707
+ if self.script_data_in is None:
1708
+ self.process_script_data_formats()
1709
+ assert self.script_data_in is not None
1710
+ return swap_nested_dict_keys(
1711
+ dct=cast("dict", self.script_data_in), inner_key="format"
1712
+ )
1668
1713
 
1669
1714
  @property
1670
- def script_data_out_grouped(self) -> Dict[str, List[str]]:
1715
+ def script_data_out_grouped(self) -> Mapping[str, Mapping[str, Mapping[str, str]]]:
1671
1716
  """Get output parameter types by script data-out format."""
1672
- return swap_nested_dict_keys(dct=self.script_data_out, inner_key="format")
1717
+ if self.script_data_out is None:
1718
+ self.process_script_data_formats()
1719
+ assert self.script_data_out is not None
1720
+ return swap_nested_dict_keys(
1721
+ dct=cast("dict", self.script_data_out), inner_key="format"
1722
+ )
1673
1723
 
1674
1724
  @property
1675
1725
  def script_data_in_has_files(self) -> bool:
1676
1726
  """Return True if the script requires some inputs to be passed via an
1677
1727
  intermediate file format."""
1678
- return bool(set(self.script_data_in_grouped.keys()) - {"direct"}) # TODO: test
1728
+ return bool(set(self.script_data_in_grouped) - {"direct"}) # TODO: test
1679
1729
 
1680
1730
  @property
1681
1731
  def script_data_out_has_files(self) -> bool:
1682
1732
  """Return True if the script produces some outputs via an intermediate file
1683
1733
  format."""
1684
- return bool(set(self.script_data_out_grouped.keys()) - {"direct"}) # TODO: test
1734
+ return bool(set(self.script_data_out_grouped) - {"direct"}) # TODO: test
1685
1735
 
1686
1736
  @property
1687
1737
  def script_data_in_has_direct(self) -> bool:
@@ -1699,12 +1749,18 @@ class Action(JSONLike):
1699
1749
  def script_is_python(self) -> bool:
1700
1750
  """Return True if the script is a Python script (determined by the file
1701
1751
  extension)"""
1702
- if self.script:
1703
- snip_path = self.get_snippet_script_path(self.script)
1704
- if snip_path:
1705
- return snip_path.suffix == ".py"
1752
+ if self.script and (snip_path := self.get_snippet_script_path(self.script)):
1753
+ return snip_path.suffix == ".py"
1754
+ return False
1755
+
1756
+ @override
1757
+ def _postprocess_to_dict(self, d: dict[str, Any]) -> dict[str, Any]:
1758
+ d = super()._postprocess_to_dict(d)
1759
+ d["script_data_in"] = d.pop("_script_data_in")
1760
+ d["script_data_out"] = d.pop("_script_data_out")
1761
+ return d
1706
1762
 
1707
- def __deepcopy__(self, memo):
1763
+ def __deepcopy__(self, memo: dict[int, Any]) -> Self:
1708
1764
  kwargs = self.to_dict()
1709
1765
  _from_expand = kwargs.pop("_from_expand")
1710
1766
  _task_schema = kwargs.pop("_task_schema", None)
@@ -1714,41 +1770,41 @@ class Action(JSONLike):
1714
1770
  return obj
1715
1771
 
1716
1772
  @property
1717
- def task_schema(self):
1773
+ def task_schema(self) -> TaskSchema:
1718
1774
  """
1719
1775
  The task schema that this action came from.
1720
1776
  """
1777
+ assert self._task_schema is not None
1721
1778
  return self._task_schema
1722
1779
 
1723
- def _resolve_input_files(self, input_files):
1780
+ def __resolve_input_files(self, input_files: list[FileSpec]) -> list[FileSpec]:
1724
1781
  in_files = input_files
1725
- for i in self.input_file_generators:
1726
- if i.input_file not in in_files:
1727
- in_files.append(i.input_file)
1782
+ for ifg in self.input_file_generators:
1783
+ if ifg.input_file not in in_files:
1784
+ in_files.append(ifg.input_file)
1728
1785
  return in_files
1729
1786
 
1730
- def _resolve_output_files(self, output_files):
1787
+ def __resolve_output_files(self, output_files: list[FileSpec]) -> list[FileSpec]:
1731
1788
  out_files = output_files
1732
- for i in self.output_file_parsers:
1733
- for j in i.output_files:
1734
- if j not in out_files:
1735
- out_files.append(j)
1789
+ for ofp in self.output_file_parsers:
1790
+ for out_file in ofp.output_files:
1791
+ if out_file not in out_files:
1792
+ out_files.append(out_file)
1736
1793
  return out_files
1737
1794
 
1738
1795
  def __repr__(self) -> str:
1739
1796
  IFGs = {
1740
- i.input_file.label: [j.typ for j in i.inputs]
1741
- for i in self.input_file_generators
1797
+ ifg.input_file.label: [inp.typ for inp in ifg.inputs]
1798
+ for ifg in self.input_file_generators
1799
+ }
1800
+ OFPs = {
1801
+ ofp.output.typ
1802
+ if ofp.output
1803
+ else f"OFP_{idx}": [out_file.label for out_file in ofp.output_files]
1804
+ for idx, ofp in enumerate(self.output_file_parsers)
1742
1805
  }
1743
- OFPs = {}
1744
- for idx, i in enumerate(self.output_file_parsers):
1745
- if i.output is not None:
1746
- key = i.output.typ
1747
- else:
1748
- key = f"OFP_{idx}"
1749
- OFPs[key] = [j.label for j in i.output_files]
1750
1806
 
1751
- out = []
1807
+ out: list[str] = []
1752
1808
  if self.commands:
1753
1809
  out.append(f"commands={self.commands!r}")
1754
1810
  if self.script:
@@ -1764,10 +1820,10 @@ class Action(JSONLike):
1764
1820
 
1765
1821
  return f"{self.__class__.__name__}({', '.join(out)})"
1766
1822
 
1767
- def __eq__(self, other):
1768
- if type(other) is not self.__class__:
1823
+ def __eq__(self, other: Any) -> bool:
1824
+ if not isinstance(other, self.__class__):
1769
1825
  return False
1770
- if (
1826
+ return (
1771
1827
  self.commands == other.commands
1772
1828
  and self.script == other.script
1773
1829
  and self.environments == other.environments
@@ -1775,63 +1831,64 @@ class Action(JSONLike):
1775
1831
  and self.input_file_generators == other.input_file_generators
1776
1832
  and self.output_file_parsers == other.output_file_parsers
1777
1833
  and self.rules == other.rules
1778
- ):
1779
- return True
1780
- return False
1834
+ )
1781
1835
 
1782
1836
  @classmethod
1783
- def _json_like_constructor(cls, json_like):
1837
+ def _json_like_constructor(cls, json_like) -> Self:
1784
1838
  """Invoked by `JSONLike.from_json_like` instead of `__init__`."""
1785
1839
  _from_expand = json_like.pop("_from_expand", None)
1786
1840
  obj = cls(**json_like)
1787
1841
  obj._from_expand = _from_expand
1788
1842
  return obj
1789
1843
 
1790
- def get_parameter_dependence(self, parameter: app.SchemaParameter):
1844
+ def get_parameter_dependence(self, parameter: SchemaParameter) -> ParameterDependence:
1791
1845
  """Find if/where a given parameter is used by the action."""
1846
+ # names of input files whose generation requires this parameter
1792
1847
  writer_files = [
1793
- i.input_file
1794
- for i in self.input_file_generators
1795
- if parameter.parameter in i.inputs
1796
- ] # names of input files whose generation requires this parameter
1797
- commands = [] # TODO: indices of commands in which this parameter appears
1798
- out = {"input_file_writers": writer_files, "commands": commands}
1799
- return out
1848
+ ifg.input_file
1849
+ for ifg in self.input_file_generators
1850
+ if parameter.parameter in ifg.inputs
1851
+ ]
1852
+ # TODO: indices of commands in which this parameter appears
1853
+ commands: list[int] = []
1854
+ return {"input_file_writers": writer_files, "commands": commands}
1800
1855
 
1801
- def _get_resolved_action_env(
1856
+ def __get_resolved_action_env(
1802
1857
  self,
1803
- relevant_scopes: Tuple[app.ActionScopeType],
1804
- input_file_generator: app.InputFileGenerator = None,
1805
- output_file_parser: app.OutputFileParser = None,
1806
- commands: List[app.Command] = None,
1807
- ):
1808
- possible = [i for i in self.environments if i.scope.typ in relevant_scopes]
1858
+ relevant_scopes: tuple[ActionScopeType, ...],
1859
+ input_file_generator: InputFileGenerator | None = None,
1860
+ output_file_parser: OutputFileParser | None = None,
1861
+ commands: list[Command] | None = None,
1862
+ ) -> ActionEnvironment:
1863
+ possible = [
1864
+ env
1865
+ for env in self.environments
1866
+ if env.scope and env.scope.typ in relevant_scopes
1867
+ ]
1809
1868
  if not possible:
1810
1869
  if input_file_generator:
1811
- msg = f"input file generator {input_file_generator.input_file.label!r}"
1870
+ raise MissingCompatibleActionEnvironment(
1871
+ f"input file generator {input_file_generator.input_file.label!r}"
1872
+ )
1812
1873
  elif output_file_parser:
1813
1874
  if output_file_parser.output is not None:
1814
1875
  ofp_id = output_file_parser.output.typ
1815
1876
  else:
1816
1877
  ofp_id = "<unnamed>"
1817
- msg = f"output file parser {ofp_id!r}"
1878
+ raise MissingCompatibleActionEnvironment(f"output file parser {ofp_id!r}")
1818
1879
  else:
1819
- msg = f"commands {commands!r}"
1820
- raise MissingCompatibleActionEnvironment(
1821
- f"No compatible environment is specified for the {msg}."
1822
- )
1880
+ raise MissingCompatibleActionEnvironment(f"commands {commands!r}")
1823
1881
 
1824
- # sort by scope type specificity:
1825
- possible_srt = sorted(possible, key=lambda i: i.scope.typ.value, reverse=True)
1826
- return possible_srt[0]
1882
+ # get max by scope type specificity:
1883
+ return max(possible, key=lambda i: i.scope.typ.value)
1827
1884
 
1828
1885
  def get_input_file_generator_action_env(
1829
- self, input_file_generator: app.InputFileGenerator
1830
- ):
1886
+ self, input_file_generator: InputFileGenerator
1887
+ ) -> ActionEnvironment:
1831
1888
  """
1832
1889
  Get the actual environment to use for an input file generator.
1833
1890
  """
1834
- return self._get_resolved_action_env(
1891
+ return self.__get_resolved_action_env(
1835
1892
  relevant_scopes=(
1836
1893
  ActionScopeType.ANY,
1837
1894
  ActionScopeType.PROCESSING,
@@ -1840,11 +1897,13 @@ class Action(JSONLike):
1840
1897
  input_file_generator=input_file_generator,
1841
1898
  )
1842
1899
 
1843
- def get_output_file_parser_action_env(self, output_file_parser: app.OutputFileParser):
1900
+ def get_output_file_parser_action_env(
1901
+ self, output_file_parser: OutputFileParser
1902
+ ) -> ActionEnvironment:
1844
1903
  """
1845
1904
  Get the actual environment to use for an output file parser.
1846
1905
  """
1847
- return self._get_resolved_action_env(
1906
+ return self.__get_resolved_action_env(
1848
1907
  relevant_scopes=(
1849
1908
  ActionScopeType.ANY,
1850
1909
  ActionScopeType.PROCESSING,
@@ -1853,11 +1912,11 @@ class Action(JSONLike):
1853
1912
  output_file_parser=output_file_parser,
1854
1913
  )
1855
1914
 
1856
- def get_commands_action_env(self):
1915
+ def get_commands_action_env(self) -> ActionEnvironment:
1857
1916
  """
1858
1917
  Get the actual environment to use for the action commands.
1859
1918
  """
1860
- return self._get_resolved_action_env(
1919
+ return self.__get_resolved_action_env(
1861
1920
  relevant_scopes=(ActionScopeType.ANY, ActionScopeType.MAIN),
1862
1921
  commands=self.commands,
1863
1922
  )
@@ -1868,43 +1927,51 @@ class Action(JSONLike):
1868
1927
  """
1869
1928
  return self.get_environment_spec()["name"]
1870
1929
 
1871
- def get_environment_spec(self) -> Dict[str, Any]:
1930
+ def get_environment_spec(self) -> Mapping[str, Any]:
1872
1931
  """
1873
1932
  Get the specification for the primary envionment, assuming it has been expanded.
1874
1933
  """
1875
1934
  if not self._from_expand:
1876
1935
  raise RuntimeError(
1877
- f"Cannot choose a single environment from this action because it is not "
1878
- f"expanded, meaning multiple action environments might exist."
1936
+ "Cannot choose a single environment from this action because it is not "
1937
+ "expanded, meaning multiple action environments might exist."
1879
1938
  )
1880
1939
  return self.environments[0].environment
1881
1940
 
1882
- def get_environment(self) -> app.Environment:
1941
+ def get_environment(self) -> Environment:
1883
1942
  """
1884
1943
  Get the primary environment.
1885
1944
  """
1886
- return self.app.envs.get(**self.get_environment_spec())
1945
+ return self._app.envs.get(**self.get_environment_spec())
1887
1946
 
1888
1947
  @staticmethod
1889
- def is_snippet_script(script: str) -> bool:
1948
+ def is_snippet_script(script: str | None) -> bool:
1890
1949
  """Returns True if the provided script string represents a script snippets that is
1891
1950
  to be modified before execution (e.g. to receive and provide parameter data)."""
1951
+ if script is None:
1952
+ return False
1892
1953
  return script.startswith("<<script:")
1893
1954
 
1955
+ __SCRIPT_NAME_RE: ClassVar[Pattern] = re.compile(
1956
+ r"\<\<script:(?:.*(?:\/|\\))*(.*)\>\>"
1957
+ )
1958
+
1894
1959
  @classmethod
1895
1960
  def get_script_name(cls, script: str) -> str:
1896
1961
  """Return the script name."""
1897
1962
  if cls.is_snippet_script(script):
1898
- pattern = r"\<\<script:(?:.*(?:\/|\\))*(.*)\>\>"
1899
- match_obj = re.match(pattern, script)
1900
- return match_obj.group(1)
1901
- else:
1902
- # a script we can expect in the working directory:
1903
- return script
1963
+ if not (match_obj := cls.__SCRIPT_NAME_RE.match(script)):
1964
+ raise ValueError("incomplete <<script:>>")
1965
+ return match_obj[1]
1966
+ # a script we can expect in the working directory:
1967
+ return script
1968
+
1969
+ __SCRIPT_RE: ClassVar[Pattern] = re.compile(r"\<\<script:(.*:?)\>\>")
1970
+ __ENV_RE: ClassVar[Pattern] = re.compile(r"\<\<env:(.*?)\>\>")
1904
1971
 
1905
1972
  @classmethod
1906
1973
  def get_snippet_script_str(
1907
- cls, script, env_spec: Optional[Dict[str, Any]] = None
1974
+ cls, script: str, env_spec: Mapping[str, Any] | None = None
1908
1975
  ) -> str:
1909
1976
  """
1910
1977
  Get the substituted script snippet path as a string.
@@ -1914,67 +1981,72 @@ class Action(JSONLike):
1914
1981
  f"Must be an app-data script name (e.g. "
1915
1982
  f"<<script:path/to/app/data/script.py>>), but received {script}"
1916
1983
  )
1917
- pattern = r"\<\<script:(.*:?)\>\>"
1918
- match_obj = re.match(pattern, script)
1919
- out = match_obj.group(1)
1984
+ if not (match_obj := cls.__SCRIPT_RE.match(script)):
1985
+ raise ValueError("incomplete <<script:>>")
1986
+ out: str = match_obj[1]
1920
1987
 
1921
1988
  if env_spec:
1922
- out = re.sub(
1923
- pattern=r"\<\<env:(.*?)\>\>",
1924
- repl=lambda match_obj: env_spec[match_obj.group(1)],
1989
+ out = cls.__ENV_RE.sub(
1990
+ repl=lambda match_obj: env_spec[match_obj[1]],
1925
1991
  string=out,
1926
1992
  )
1927
1993
  return out
1928
1994
 
1929
1995
  @classmethod
1930
1996
  def get_snippet_script_path(
1931
- cls, script_path, env_spec: Optional[Dict[str, Any]] = None
1932
- ) -> Path:
1997
+ cls, script_path: str | None, env_spec: Mapping[str, Any] | None = None
1998
+ ) -> Path | None:
1933
1999
  """
1934
2000
  Get the substituted script snippet path, or False if there is no snippet.
1935
2001
  """
1936
2002
  if not cls.is_snippet_script(script_path):
1937
- return False
2003
+ return None
1938
2004
 
2005
+ assert script_path is not None
1939
2006
  path = cls.get_snippet_script_str(script_path, env_spec)
1940
- if path in cls.app.scripts:
1941
- path = cls.app.scripts.get(path)
1942
-
1943
- return Path(path)
2007
+ return Path(cls._app.scripts.get(path, path))
1944
2008
 
1945
2009
  @staticmethod
1946
- def __get_param_dump_file_stem(js_idx: int, js_act_idx: int):
2010
+ def __get_param_dump_file_stem(js_idx: int | str, js_act_idx: int | str) -> str:
1947
2011
  return RunDirAppFiles.get_run_param_dump_file_prefix(js_idx, js_act_idx)
1948
2012
 
1949
2013
  @staticmethod
1950
- def __get_param_load_file_stem(js_idx: int, js_act_idx: int):
2014
+ def __get_param_load_file_stem(js_idx: int | str, js_act_idx: int | str) -> str:
1951
2015
  return RunDirAppFiles.get_run_param_load_file_prefix(js_idx, js_act_idx)
1952
2016
 
1953
- def get_param_dump_file_path_JSON(self, js_idx: int, js_act_idx: int):
2017
+ def get_param_dump_file_path_JSON(
2018
+ self, js_idx: int | str, js_act_idx: int | str
2019
+ ) -> Path:
1954
2020
  """
1955
2021
  Get the path of the JSON dump file.
1956
2022
  """
1957
2023
  return Path(self.__get_param_dump_file_stem(js_idx, js_act_idx) + ".json")
1958
2024
 
1959
- def get_param_dump_file_path_HDF5(self, js_idx: int, js_act_idx: int):
2025
+ def get_param_dump_file_path_HDF5(
2026
+ self, js_idx: int | str, js_act_idx: int | str
2027
+ ) -> Path:
1960
2028
  """
1961
2029
  Get the path of the HDF56 dump file.
1962
2030
  """
1963
2031
  return Path(self.__get_param_dump_file_stem(js_idx, js_act_idx) + ".h5")
1964
2032
 
1965
- def get_param_load_file_path_JSON(self, js_idx: int, js_act_idx: int):
2033
+ def get_param_load_file_path_JSON(
2034
+ self, js_idx: int | str, js_act_idx: int | str
2035
+ ) -> Path:
1966
2036
  """
1967
2037
  Get the path of the JSON load file.
1968
2038
  """
1969
2039
  return Path(self.__get_param_load_file_stem(js_idx, js_act_idx) + ".json")
1970
2040
 
1971
- def get_param_load_file_path_HDF5(self, js_idx: int, js_act_idx: int):
2041
+ def get_param_load_file_path_HDF5(
2042
+ self, js_idx: int | str, js_act_idx: int | str
2043
+ ) -> Path:
1972
2044
  """
1973
2045
  Get the path of the HDF5 load file.
1974
2046
  """
1975
2047
  return Path(self.__get_param_load_file_stem(js_idx, js_act_idx) + ".h5")
1976
2048
 
1977
- def expand(self):
2049
+ def expand(self) -> Sequence[Action]:
1978
2050
  """
1979
2051
  Expand this action into a list of actions if necessary.
1980
2052
  This converts input file generators and output file parsers into their own actions.
@@ -1983,154 +2055,159 @@ class Action(JSONLike):
1983
2055
  # already expanded
1984
2056
  return [self]
1985
2057
 
1986
- else:
1987
- # run main if:
1988
- # - one or more output files are not passed
1989
- # run IFG if:
1990
- # - one or more output files are not passed
1991
- # - AND input file is not passed
1992
- # always run OPs, for now
1993
-
1994
- main_rules = self.rules + [
1995
- self.app.ActionRule.check_missing(f"output_files.{i.label}")
1996
- for i in self.output_files
1997
- ]
2058
+ # run main if:
2059
+ # - one or more output files are not passed
2060
+ # run IFG if:
2061
+ # - one or more output files are not passed
2062
+ # - AND input file is not passed
2063
+ # always run OPs, for now
1998
2064
 
1999
- # note we keep the IFG/OPs in the new actions, so we can check the parameters
2000
- # used/produced.
2065
+ main_rules = self.rules + [
2066
+ self._app.ActionRule.check_missing(f"output_files.{of.label}")
2067
+ for of in self.output_files
2068
+ ]
2001
2069
 
2002
- inp_files = []
2003
- inp_acts = []
2004
- for ifg in self.input_file_generators:
2005
- exe = "<<executable:python_script>>"
2006
- args = [
2007
- '"$WK_PATH"',
2008
- "$EAR_ID",
2009
- ] # WK_PATH could have a space in it
2010
- if ifg.script:
2011
- script_name = self.get_script_name(ifg.script)
2012
- variables = {
2013
- "script_name": script_name,
2014
- "script_name_no_ext": str(Path(script_name).stem),
2015
- }
2016
- else:
2017
- variables = {}
2018
- act_i = self.app.Action(
2019
- commands=[
2020
- app.Command(executable=exe, arguments=args, variables=variables)
2021
- ],
2022
- input_file_generators=[ifg],
2023
- environments=[self.get_input_file_generator_action_env(ifg)],
2024
- rules=main_rules + ifg.get_action_rules(),
2025
- script_pass_env_spec=ifg.script_pass_env_spec,
2026
- abortable=ifg.abortable,
2027
- # TODO: add script_data_in etc? and to OFP?
2028
- )
2029
- act_i._task_schema = self.task_schema
2030
- if ifg.input_file not in inp_files:
2031
- inp_files.append(ifg.input_file)
2032
- act_i._from_expand = True
2033
- inp_acts.append(act_i)
2034
-
2035
- out_files = []
2036
- out_acts = []
2037
- for ofp in self.output_file_parsers:
2038
- exe = "<<executable:python_script>>"
2039
- args = [
2040
- '"$WK_PATH"',
2041
- "$EAR_ID",
2042
- ] # WK_PATH could have a space in it
2043
- if ofp.script:
2044
- script_name = self.get_script_name(ofp.script)
2045
- variables = {
2046
- "script_name": script_name,
2047
- "script_name_no_ext": str(Path(script_name).stem),
2048
- }
2049
- else:
2050
- variables = {}
2051
- act_i = self.app.Action(
2052
- commands=[
2053
- app.Command(executable=exe, arguments=args, variables=variables)
2054
- ],
2055
- output_file_parsers=[ofp],
2056
- environments=[self.get_output_file_parser_action_env(ofp)],
2057
- rules=list(self.rules) + ofp.get_action_rules(),
2058
- script_pass_env_spec=ofp.script_pass_env_spec,
2059
- abortable=ofp.abortable,
2060
- )
2061
- act_i._task_schema = self.task_schema
2062
- for j in ofp.output_files:
2063
- if j not in out_files:
2064
- out_files.append(j)
2065
- act_i._from_expand = True
2066
- out_acts.append(act_i)
2067
-
2068
- commands = self.commands
2070
+ # note we keep the IFG/OPs in the new actions, so we can check the parameters
2071
+ # used/produced.
2072
+
2073
+ args: list[str]
2074
+ inp_files = []
2075
+ inp_acts: list[Action] = []
2076
+ for ifg in self.input_file_generators:
2077
+ exe = "<<executable:python_script>>"
2078
+ args = [
2079
+ '"$WK_PATH"',
2080
+ "$EAR_ID",
2081
+ ] # WK_PATH could have a space in it
2082
+ if ifg.script:
2083
+ script_name = self.get_script_name(ifg.script)
2084
+ variables = {
2085
+ "script_name": script_name,
2086
+ "script_name_no_ext": str(Path(script_name).stem),
2087
+ }
2088
+ else:
2089
+ variables = {}
2090
+ act_i = self._app.Action(
2091
+ commands=[
2092
+ self._app.Command(executable=exe, arguments=args, variables=variables)
2093
+ ],
2094
+ input_file_generators=[ifg],
2095
+ environments=[self.get_input_file_generator_action_env(ifg)],
2096
+ rules=main_rules + ifg.get_action_rules(),
2097
+ script_pass_env_spec=ifg.script_pass_env_spec,
2098
+ abortable=ifg.abortable,
2099
+ # TODO: add script_data_in etc? and to OFP?
2100
+ )
2101
+ act_i._task_schema = self.task_schema
2102
+ if ifg.input_file not in inp_files:
2103
+ inp_files.append(ifg.input_file)
2104
+ act_i._from_expand = True
2105
+ inp_acts.append(act_i)
2106
+
2107
+ out_files: list[FileSpec] = []
2108
+ out_acts: list[Action] = []
2109
+ for ofp in self.output_file_parsers:
2110
+ exe = "<<executable:python_script>>"
2111
+ args = [
2112
+ '"$WK_PATH"',
2113
+ "$EAR_ID",
2114
+ ] # WK_PATH could have a space in it
2115
+ if ofp.script:
2116
+ script_name = self.get_script_name(ofp.script)
2117
+ variables = {
2118
+ "script_name": script_name,
2119
+ "script_name_no_ext": str(Path(script_name).stem),
2120
+ }
2121
+ else:
2122
+ variables = {}
2123
+ act_i = self._app.Action(
2124
+ commands=[
2125
+ self._app.Command(executable=exe, arguments=args, variables=variables)
2126
+ ],
2127
+ output_file_parsers=[ofp],
2128
+ environments=[self.get_output_file_parser_action_env(ofp)],
2129
+ rules=[*self.rules, *ofp.get_action_rules()],
2130
+ script_pass_env_spec=ofp.script_pass_env_spec,
2131
+ abortable=ofp.abortable,
2132
+ )
2133
+ act_i._task_schema = self.task_schema
2134
+ for out_f in ofp.output_files:
2135
+ if out_f not in out_files:
2136
+ out_files.append(out_f)
2137
+ act_i._from_expand = True
2138
+ out_acts.append(act_i)
2139
+
2140
+ commands = self.commands
2141
+ if self.script:
2142
+ exe = f"<<executable:{self.script_exe}>>"
2143
+ args = []
2069
2144
  if self.script:
2070
- exe = f"<<executable:{self.script_exe}>>"
2071
- args = []
2072
- if self.script:
2073
- script_name = self.get_script_name(self.script)
2074
- variables = {
2075
- "script_name": script_name,
2076
- "script_name_no_ext": str(Path(script_name).stem),
2077
- }
2078
- else:
2079
- variables = {}
2080
- if self.script_data_in_has_direct or self.script_data_out_has_direct:
2081
- # WK_PATH could have a space in it:
2082
- args.extend(["--wk-path", '"$WK_PATH"', "--run-id", "$EAR_ID"])
2083
-
2084
- fn_args = {"js_idx": r"${JS_IDX}", "js_act_idx": r"${JS_act_idx}"}
2085
-
2086
- for fmt in self.script_data_in_grouped:
2087
- if fmt == "json":
2088
- if self.script_data_files_use_opt:
2089
- args.append("--inputs-json")
2090
- args.append(str(self.get_param_dump_file_path_JSON(**fn_args)))
2091
- elif fmt == "hdf5":
2092
- if self.script_data_files_use_opt:
2093
- args.append("--inputs-hdf5")
2094
- args.append(str(self.get_param_dump_file_path_HDF5(**fn_args)))
2095
-
2096
- for fmt in self.script_data_out_grouped:
2097
- if fmt == "json":
2098
- if self.script_data_files_use_opt:
2099
- args.append("--outputs-json")
2100
- args.append(str(self.get_param_load_file_path_JSON(**fn_args)))
2101
- elif fmt == "hdf5":
2102
- if self.script_data_files_use_opt:
2103
- args.append("--outputs-hdf5")
2104
- args.append(str(self.get_param_load_file_path_HDF5(**fn_args)))
2105
-
2106
- commands += [
2107
- self.app.Command(executable=exe, arguments=args, variables=variables)
2108
- ]
2109
-
2110
- # TODO: store script_args? and build command with executable syntax?
2111
- main_act = self.app.Action(
2112
- commands=commands,
2113
- script=self.script,
2114
- script_data_in=self.script_data_in,
2115
- script_data_out=self.script_data_out,
2116
- script_exe=self.script_exe,
2117
- script_pass_env_spec=self.script_pass_env_spec,
2118
- environments=[self.get_commands_action_env()],
2119
- abortable=self.abortable,
2120
- rules=main_rules,
2121
- input_files=inp_files,
2122
- output_files=out_files,
2123
- save_files=self.save_files,
2124
- clean_up=self.clean_up,
2145
+ script_name = self.get_script_name(self.script)
2146
+ variables = {
2147
+ "script_name": script_name,
2148
+ "script_name_no_ext": str(Path(script_name).stem),
2149
+ }
2150
+ else:
2151
+ variables = {}
2152
+ if self.script_data_in_has_direct or self.script_data_out_has_direct:
2153
+ # WK_PATH could have a space in it:
2154
+ args.extend(("--wk-path", '"$WK_PATH"', "--run-id", "$EAR_ID"))
2155
+
2156
+ fn_args = {"js_idx": "${JS_IDX}", "js_act_idx": "${JS_act_idx}"}
2157
+
2158
+ for fmt in self.script_data_in_grouped:
2159
+ if fmt == "json":
2160
+ if self.script_data_files_use_opt:
2161
+ args.append("--inputs-json")
2162
+ args.append(str(self.get_param_dump_file_path_JSON(**fn_args)))
2163
+ elif fmt == "hdf5":
2164
+ if self.script_data_files_use_opt:
2165
+ args.append("--inputs-hdf5")
2166
+ args.append(str(self.get_param_dump_file_path_HDF5(**fn_args)))
2167
+
2168
+ for fmt in self.script_data_out_grouped:
2169
+ if fmt == "json":
2170
+ if self.script_data_files_use_opt:
2171
+ args.append("--outputs-json")
2172
+ args.append(str(self.get_param_load_file_path_JSON(**fn_args)))
2173
+ elif fmt == "hdf5":
2174
+ if self.script_data_files_use_opt:
2175
+ args.append("--outputs-hdf5")
2176
+ args.append(str(self.get_param_load_file_path_HDF5(**fn_args)))
2177
+
2178
+ commands.append(
2179
+ self._app.Command(executable=exe, arguments=args, variables=variables)
2125
2180
  )
2126
- main_act._task_schema = self.task_schema
2127
- main_act._from_expand = True
2128
2181
 
2129
- cmd_acts = inp_acts + [main_act] + out_acts
2182
+ # TODO: store script_args? and build command with executable syntax?
2183
+ main_act = self._app.Action(
2184
+ commands=commands,
2185
+ script=self.script,
2186
+ script_data_in=self.script_data_in,
2187
+ script_data_out=self.script_data_out,
2188
+ script_exe=self.script_exe,
2189
+ script_pass_env_spec=self.script_pass_env_spec,
2190
+ environments=[self.get_commands_action_env()],
2191
+ abortable=self.abortable,
2192
+ rules=main_rules,
2193
+ input_files=inp_files,
2194
+ output_files=out_files,
2195
+ save_files=self.save_files,
2196
+ clean_up=self.clean_up,
2197
+ )
2198
+ main_act._task_schema = self.task_schema
2199
+ main_act._from_expand = True
2200
+ main_act.process_script_data_formats()
2130
2201
 
2131
- return cmd_acts
2202
+ return [*inp_acts, main_act, *out_acts]
2132
2203
 
2133
- def get_command_input_types(self, sub_parameters: bool = False) -> Tuple[str]:
2204
+ # note: we use "parameter" rather than "input", because it could be a schema input
2205
+ # or schema output.
2206
+ __PARAMS_RE: ClassVar[Pattern] = re.compile(
2207
+ r"\<\<(?:\w+(?:\[(?:.*)\])?\()?parameter:(.*?)\)?\>\>"
2208
+ )
2209
+
2210
+ def get_command_input_types(self, sub_parameters: bool = False) -> tuple[str, ...]:
2134
2211
  """Get parameter types from commands.
2135
2212
 
2136
2213
  Parameters
@@ -2140,49 +2217,44 @@ class Action(JSONLike):
2140
2217
  untouched. If False (default), only return the root parameter type and
2141
2218
  disregard the sub-parameter part.
2142
2219
  """
2143
- params = []
2144
- # note: we use "parameter" rather than "input", because it could be a schema input
2145
- # or schema output.
2146
- vars_regex = r"\<\<(?:\w+(?:\[(?:.*)\])?\()?parameter:(.*?)\)?\>\>"
2220
+ params: set[str] = set()
2147
2221
  for command in self.commands:
2148
- for val in re.findall(vars_regex, command.command or ""):
2149
- if not sub_parameters:
2150
- val = val.split(".")[0]
2151
- params.append(val)
2152
- for arg in command.arguments or []:
2153
- for val in re.findall(vars_regex, arg):
2154
- if not sub_parameters:
2155
- val = val.split(".")[0]
2156
- params.append(val)
2222
+ params.update(
2223
+ val[1] if sub_parameters else val[1].split(".")[0]
2224
+ for val in self.__PARAMS_RE.finditer(command.command or "")
2225
+ )
2226
+ for arg in command.arguments or ():
2227
+ params.update(
2228
+ val[1] if sub_parameters else val[1].split(".")[0]
2229
+ for val in self.__PARAMS_RE.finditer(arg)
2230
+ )
2157
2231
  # TODO: consider stdin?
2158
- return tuple(set(params))
2232
+ return tuple(params)
2233
+
2234
+ __FILES_RE: ClassVar[Pattern] = re.compile(r"\<\<file:(.*?)\>\>")
2159
2235
 
2160
- def get_command_input_file_labels(self) -> Tuple[str]:
2236
+ def get_command_input_file_labels(self) -> tuple[str, ...]:
2161
2237
  """Get input files types from commands."""
2162
- files = []
2163
- vars_regex = r"\<\<file:(.*?)\>\>"
2238
+ files: set[str] = set()
2164
2239
  for command in self.commands:
2165
- for val in re.findall(vars_regex, command.command or ""):
2166
- files.append(val)
2167
- for arg in command.arguments or []:
2168
- for val in re.findall(vars_regex, arg):
2169
- files.append(val)
2240
+ files.update(self.__FILES_RE.findall(command.command or ""))
2241
+ for arg in command.arguments or ():
2242
+ files.update(self.__FILES_RE.findall(arg))
2170
2243
  # TODO: consider stdin?
2171
- return tuple(set(files))
2244
+ return tuple(files)
2172
2245
 
2173
- def get_command_output_types(self) -> Tuple[str]:
2246
+ def get_command_output_types(self) -> tuple[str, ...]:
2174
2247
  """Get parameter types from command stdout and stderr arguments."""
2175
- params = []
2248
+ params: set[str] = set()
2176
2249
  for command in self.commands:
2177
2250
  out_params = command.get_output_types()
2178
2251
  if out_params["stdout"]:
2179
- params.append(out_params["stdout"])
2252
+ params.add(out_params["stdout"])
2180
2253
  if out_params["stderr"]:
2181
- params.append(out_params["stderr"])
2254
+ params.add(out_params["stderr"])
2255
+ return tuple(params)
2182
2256
 
2183
- return tuple(set(params))
2184
-
2185
- def get_input_types(self, sub_parameters: bool = False) -> Tuple[str]:
2257
+ def get_input_types(self, sub_parameters: bool = False) -> tuple[str, ...]:
2186
2258
  """Get the input types that are consumed by commands and input file generators of
2187
2259
  this action.
2188
2260
 
@@ -2193,80 +2265,75 @@ class Action(JSONLike):
2193
2265
  inputs will be returned untouched. If False (default), only return the root
2194
2266
  parameter type and disregard the sub-parameter part.
2195
2267
  """
2196
- is_script = (
2268
+ if (
2197
2269
  self.script
2198
2270
  and not self.input_file_generators
2199
2271
  and not self.output_file_parsers
2200
- )
2201
- if is_script:
2202
- params = self.task_schema.input_types
2272
+ ):
2273
+ params = set(self.task_schema.input_types)
2203
2274
  else:
2204
- params = list(self.get_command_input_types(sub_parameters))
2205
- for i in self.input_file_generators:
2206
- params.extend([j.typ for j in i.inputs])
2207
- for i in self.output_file_parsers:
2208
- params.extend([j for j in i.inputs or []])
2209
- return tuple(set(params))
2210
-
2211
- def get_output_types(self) -> Tuple[str]:
2275
+ params = set(self.get_command_input_types(sub_parameters))
2276
+ for ifg in self.input_file_generators:
2277
+ params.update(inp.typ for inp in ifg.inputs)
2278
+ for ofp in self.output_file_parsers:
2279
+ params.update(ofp.inputs or ())
2280
+ return tuple(params)
2281
+
2282
+ def get_output_types(self) -> tuple[str, ...]:
2212
2283
  """Get the output types that are produced by command standard outputs and errors,
2213
2284
  and by output file parsers of this action."""
2214
- is_script = (
2285
+ if (
2215
2286
  self.script
2216
2287
  and not self.input_file_generators
2217
2288
  and not self.output_file_parsers
2218
- )
2219
- if is_script:
2220
- params = self.task_schema.output_types
2289
+ ):
2290
+ params = set(self.task_schema.output_types)
2221
2291
  else:
2222
- params = list(self.get_command_output_types())
2223
- for i in self.output_file_parsers:
2224
- if i.output is not None:
2225
- params.append(i.output.typ)
2226
- params.extend([j for j in i.outputs or []])
2227
- return tuple(set(params))
2292
+ params = set(self.get_command_output_types())
2293
+ for ofp in self.output_file_parsers:
2294
+ if ofp.output is not None:
2295
+ params.add(ofp.output.typ)
2296
+ params.update(ofp.outputs or ())
2297
+ return tuple(params)
2228
2298
 
2229
- def get_input_file_labels(self):
2299
+ def get_input_file_labels(self) -> tuple[str, ...]:
2230
2300
  """
2231
2301
  Get the labels from the input files.
2232
2302
  """
2233
- return tuple(i.label for i in self.input_files)
2303
+ return tuple(in_f.label for in_f in self.input_files)
2234
2304
 
2235
- def get_output_file_labels(self):
2305
+ def get_output_file_labels(self) -> tuple[str, ...]:
2236
2306
  """
2237
2307
  Get the labels from the output files.
2238
2308
  """
2239
- return tuple(i.label for i in self.output_files)
2309
+ return tuple(out_f.label for out_f in self.output_files)
2240
2310
 
2241
2311
  @TimeIt.decorator
2242
2312
  def generate_data_index(
2243
2313
  self,
2244
- act_idx,
2245
- EAR_ID,
2246
- schema_data_idx,
2247
- all_data_idx,
2248
- workflow,
2249
- param_source,
2250
- ) -> List[int]:
2314
+ act_idx: int,
2315
+ EAR_ID: int,
2316
+ schema_data_idx: DataIndex,
2317
+ all_data_idx: dict[tuple[int, int], DataIndex],
2318
+ workflow: Workflow,
2319
+ param_source: ParamSource,
2320
+ ) -> list[int | list[int]]:
2251
2321
  """Generate the data index for this action of an element iteration whose overall
2252
2322
  data index is passed.
2253
2323
 
2254
2324
  This mutates `all_data_idx`.
2255
-
2256
2325
  """
2257
2326
 
2258
2327
  # output keys must be processed first for this to work, since when processing an
2259
2328
  # output key, we may need to update the index of an output in a previous action's
2260
2329
  # data index, which could affect the data index in an input of this action.
2261
- keys = [f"outputs.{i}" for i in self.get_output_types()]
2262
- keys += [f"inputs.{i}" for i in self.get_input_types()]
2263
- for i in self.input_files:
2264
- keys.append(f"input_files.{i.label}")
2265
- for i in self.output_files:
2266
- keys.append(f"output_files.{i.label}")
2330
+ keys = [f"outputs.{typ}" for typ in self.get_output_types()]
2331
+ keys.extend(f"inputs.{typ}" for typ in self.get_input_types())
2332
+ keys.extend(f"input_files.{file.label}" for file in self.input_files)
2333
+ keys.extend(f"output_files.{file.label}" for file in self.output_files)
2267
2334
 
2268
2335
  # these are consumed by the OFP, so should not be considered to generate new data:
2269
- OFP_outs = [j for i in self.output_file_parsers for j in i.outputs or []]
2336
+ OFP_outs = {j for ofp in self.output_file_parsers for j in ofp.outputs or ()}
2270
2337
 
2271
2338
  # keep all resources and repeats data:
2272
2339
  sub_data_idx = {
@@ -2274,37 +2341,40 @@ class Action(JSONLike):
2274
2341
  for k, v in schema_data_idx.items()
2275
2342
  if ("resources" in k or "repeats" in k)
2276
2343
  }
2277
- param_src_update = []
2344
+ param_src_update: list[int | list[int]] = []
2278
2345
  for key in keys:
2279
- sub_param_idx = {}
2346
+ sub_param_idx: dict[str, int | list[int]] = {}
2280
2347
  if (
2281
2348
  key.startswith("input_files")
2282
2349
  or key.startswith("output_files")
2283
2350
  or key.startswith("inputs")
2284
- or (key.startswith("outputs") and key.split("outputs.")[1] in OFP_outs)
2351
+ or (
2352
+ key.startswith("outputs") and key.removeprefix("outputs.") in OFP_outs
2353
+ )
2285
2354
  ):
2286
2355
  # look for an index in previous data indices (where for inputs we look
2287
2356
  # for *output* parameters of the same name):
2288
- k_idx = None
2357
+ k_idx: int | list[int] | None = None
2289
2358
  for prev_data_idx in all_data_idx.values():
2290
2359
  if key.startswith("inputs"):
2291
- k_param = key.split("inputs.")[1]
2360
+ k_param = key.removeprefix("inputs.")
2292
2361
  k_out = f"outputs.{k_param}"
2293
2362
  if k_out in prev_data_idx:
2294
2363
  k_idx = prev_data_idx[k_out]
2295
-
2296
- else:
2297
- if key in prev_data_idx:
2298
- k_idx = prev_data_idx[key]
2364
+ elif key in prev_data_idx:
2365
+ k_idx = prev_data_idx[key]
2299
2366
 
2300
2367
  if k_idx is None:
2301
2368
  # otherwise take from the schema_data_idx:
2302
2369
  if key in schema_data_idx:
2303
2370
  k_idx = schema_data_idx[key]
2371
+ prefix = f"{key}." # sub-parameter (note dot)
2304
2372
  # add any associated sub-parameters:
2305
- for k, v in schema_data_idx.items():
2306
- if k.startswith(f"{key}."): # sub-parameter (note dot)
2307
- sub_param_idx[k] = v
2373
+ sub_param_idx.update(
2374
+ (k, v)
2375
+ for k, v in schema_data_idx.items()
2376
+ if k.startswith(prefix)
2377
+ )
2308
2378
  else:
2309
2379
  # otherwise we need to allocate a new parameter datum:
2310
2380
  # (for input/output_files keys)
@@ -2313,13 +2383,12 @@ class Action(JSONLike):
2313
2383
  else:
2314
2384
  # outputs
2315
2385
  k_idx = None
2316
- for (act_idx_i, EAR_ID_i), prev_data_idx in all_data_idx.items():
2386
+ for (_, EAR_ID_i), prev_data_idx in all_data_idx.items():
2317
2387
  if key in prev_data_idx:
2318
2388
  k_idx = prev_data_idx[key]
2319
2389
 
2320
2390
  # allocate a new parameter datum for this intermediate output:
2321
- param_source_i = copy.deepcopy(param_source)
2322
- # param_source_i["action_idx"] = act_idx_i
2391
+ param_source_i = copy.copy(param_source)
2323
2392
  param_source_i["EAR_ID"] = EAR_ID_i
2324
2393
  new_k_idx = workflow._add_unset_parameter_data(param_source_i)
2325
2394
 
@@ -2336,36 +2405,48 @@ class Action(JSONLike):
2336
2405
  sub_data_idx[key] = k_idx
2337
2406
  sub_data_idx.update(sub_param_idx)
2338
2407
 
2339
- all_data_idx[(act_idx, EAR_ID)] = sub_data_idx
2408
+ all_data_idx[act_idx, EAR_ID] = sub_data_idx
2340
2409
 
2341
2410
  return param_src_update
2342
2411
 
2343
- def get_possible_scopes(self) -> Tuple[app.ActionScope]:
2412
+ def get_possible_scopes(self) -> tuple[ActionScope, ...]:
2344
2413
  """Get the action scopes that are inclusive of this action, ordered by decreasing
2345
2414
  specificity."""
2346
2415
 
2347
2416
  scope = self.get_precise_scope()
2348
-
2349
2417
  if self.input_file_generators:
2350
- scopes = (
2418
+ return (
2351
2419
  scope,
2352
- self.app.ActionScope.input_file_generator(),
2353
- self.app.ActionScope.processing(),
2354
- self.app.ActionScope.any(),
2420
+ self._app.ActionScope.input_file_generator(),
2421
+ self._app.ActionScope.processing(),
2422
+ self._app.ActionScope.any(),
2355
2423
  )
2356
2424
  elif self.output_file_parsers:
2357
- scopes = (
2425
+ return (
2358
2426
  scope,
2359
- self.app.ActionScope.output_file_parser(),
2360
- self.app.ActionScope.processing(),
2361
- self.app.ActionScope.any(),
2427
+ self._app.ActionScope.output_file_parser(),
2428
+ self._app.ActionScope.processing(),
2429
+ self._app.ActionScope.any(),
2362
2430
  )
2363
2431
  else:
2364
- scopes = (scope, self.app.ActionScope.any())
2432
+ return (scope, self._app.ActionScope.any())
2433
+
2434
+ def _get_possible_scopes_reversed(self) -> Iterator[ActionScope]:
2435
+ """Get the action scopes that are inclusive of this action, ordered by increasing
2436
+ specificity."""
2365
2437
 
2366
- return scopes
2438
+ # Fail early if a failure is possible
2439
+ precise_scope = self.get_precise_scope()
2440
+ yield self._app.ActionScope.any()
2441
+ if self.input_file_generators:
2442
+ yield self._app.ActionScope.processing()
2443
+ yield self._app.ActionScope.input_file_generator()
2444
+ elif self.output_file_parsers:
2445
+ yield self._app.ActionScope.processing()
2446
+ yield self._app.ActionScope.output_file_parser()
2447
+ yield precise_scope
2367
2448
 
2368
- def get_precise_scope(self) -> app.ActionScope:
2449
+ def get_precise_scope(self) -> ActionScope:
2369
2450
  """
2370
2451
  Get the exact scope of this action.
2371
2452
  The action must have been expanded prior to calling this.
@@ -2377,21 +2458,21 @@ class Action(JSONLike):
2377
2458
  )
2378
2459
 
2379
2460
  if self.input_file_generators:
2380
- return self.app.ActionScope.input_file_generator(
2461
+ return self._app.ActionScope.input_file_generator(
2381
2462
  file=self.input_file_generators[0].input_file.label
2382
2463
  )
2383
2464
  elif self.output_file_parsers:
2384
2465
  if self.output_file_parsers[0].output is not None:
2385
- return self.app.ActionScope.output_file_parser(
2466
+ return self._app.ActionScope.output_file_parser(
2386
2467
  output=self.output_file_parsers[0].output
2387
2468
  )
2388
2469
  else:
2389
- return self.app.ActionScope.output_file_parser()
2470
+ return self._app.ActionScope.output_file_parser()
2390
2471
  else:
2391
- return self.app.ActionScope.main()
2472
+ return self._app.ActionScope.main()
2392
2473
 
2393
2474
  def is_input_type_required(
2394
- self, typ: str, provided_files: List[app.FileSpec]
2475
+ self, typ: str, provided_files: Container[FileSpec]
2395
2476
  ) -> bool:
2396
2477
  """
2397
2478
  Determine if the given input type is required by this action.
@@ -2410,38 +2491,30 @@ class Action(JSONLike):
2410
2491
 
2411
2492
  # typ is required if used in any input file generators and input file is not
2412
2493
  # provided:
2413
- for IFG in self.input_file_generators:
2414
- if typ in (i.typ for i in IFG.inputs):
2415
- if IFG.input_file not in provided_files:
2416
- return True
2417
-
2418
- # typ is required if used in any output file parser
2419
- for OFP in self.output_file_parsers:
2420
- if typ in (OFP.inputs or []):
2494
+ for ifg in self.input_file_generators:
2495
+ if typ in (inp.typ for inp in ifg.inputs) and (
2496
+ ifg.input_file not in provided_files
2497
+ ):
2421
2498
  return True
2422
2499
 
2423
- # Appears to be not required
2424
- return False
2500
+ # typ is required if used in any output file parser
2501
+ return any(typ in (ofp.inputs or ()) for ofp in self.output_file_parsers)
2425
2502
 
2426
2503
  @TimeIt.decorator
2427
- def test_rules(self, element_iter) -> Tuple[bool, List[int]]:
2504
+ def test_rules(self, element_iter: ElementIteration) -> tuple[bool, list[int]]:
2428
2505
  """Test all rules against the specified element iteration."""
2429
- rules_valid = [rule.test(element_iteration=element_iter) for rule in self.rules]
2430
- action_valid = all(rules_valid)
2431
- commands_idx = []
2432
- if action_valid:
2433
- for cmd_idx, cmd in enumerate(self.commands):
2434
- if any(not i.test(element_iteration=element_iter) for i in cmd.rules):
2435
- continue
2436
- commands_idx.append(cmd_idx)
2437
- return action_valid, commands_idx
2438
-
2439
- def get_required_executables(self) -> Tuple[str]:
2506
+ if any(not rule.test(element_iteration=element_iter) for rule in self.rules):
2507
+ return False, []
2508
+ return True, [
2509
+ cmd_idx
2510
+ for cmd_idx, cmd in enumerate(self.commands)
2511
+ if all(rule.test(element_iteration=element_iter) for rule in cmd.rules)
2512
+ ]
2513
+
2514
+ def get_required_executables(self) -> Iterator[str]:
2440
2515
  """Return executable labels required by this action."""
2441
- exec_labs = []
2442
2516
  for command in self.commands:
2443
- exec_labs.extend(command.get_required_executables())
2444
- return tuple(set(exec_labs))
2517
+ yield from command.get_required_executables()
2445
2518
 
2446
2519
  def compose_source(self, snip_path: Path) -> str:
2447
2520
  """Generate the file contents of this source."""
@@ -2453,8 +2526,7 @@ class Action(JSONLike):
2453
2526
  if not self.script_is_python:
2454
2527
  return script_str
2455
2528
 
2456
- py_imports = dedent(
2457
- """\
2529
+ py_imports = """
2458
2530
  import argparse, sys
2459
2531
  from pathlib import Path
2460
2532
 
@@ -2466,29 +2538,25 @@ class Action(JSONLike):
2466
2538
  parser.add_argument("--outputs-json")
2467
2539
  parser.add_argument("--outputs-hdf5")
2468
2540
  args = parser.parse_args()
2469
-
2470
- """
2471
- )
2541
+ """
2472
2542
 
2473
2543
  # if any direct inputs/outputs, we must load the workflow (must be python):
2474
2544
  if self.script_data_in_has_direct or self.script_data_out_has_direct:
2475
- py_main_block_workflow_load = dedent(
2476
- """\
2477
- import {app_module} as app
2478
- app.load_config(
2479
- log_file_path=Path("{run_log_file}").resolve(),
2480
- config_dir=r"{cfg_dir}",
2481
- config_key=r"{cfg_invoc_key}",
2482
- )
2483
- wk_path, EAR_ID = args.wk_path, args.run_id
2484
- wk = app.Workflow(wk_path)
2485
- EAR = wk.get_EARs_from_IDs([EAR_ID])[0]
2486
- """
2487
- ).format(
2488
- run_log_file=self.app.RunDirAppFiles.get_log_file_name(),
2489
- app_module=self.app.module,
2490
- cfg_dir=self.app.config.config_directory,
2491
- cfg_invoc_key=self.app.config.config_key,
2545
+ py_main_block_workflow_load = """
2546
+ import {app_module} as app
2547
+ app.load_config(
2548
+ log_file_path=Path("{run_log_file}").resolve(),
2549
+ config_dir=r"{cfg_dir}",
2550
+ config_key=r"{cfg_invoc_key}",
2551
+ )
2552
+ wk_path, EAR_ID = args.wk_path, args.run_id
2553
+ wk = app.Workflow(wk_path)
2554
+ EAR = wk.get_EARs_from_IDs([EAR_ID])[0]
2555
+ """.format(
2556
+ run_log_file=self._app.RunDirAppFiles.get_log_file_name(),
2557
+ app_module=self._app.module,
2558
+ cfg_dir=self._app.config.config_directory,
2559
+ cfg_invoc_key=self._app.config.config_key,
2492
2560
  )
2493
2561
  else:
2494
2562
  py_main_block_workflow_load = ""
@@ -2496,40 +2564,33 @@ class Action(JSONLike):
2496
2564
  func_kwargs_lst = []
2497
2565
  if "direct" in self.script_data_in_grouped:
2498
2566
  direct_ins_str = "direct_ins = EAR.get_input_values_direct()"
2499
- direct_ins_arg_str = "**direct_ins"
2500
- func_kwargs_lst.append(direct_ins_arg_str)
2567
+ func_kwargs_lst.append("**direct_ins")
2501
2568
  else:
2502
2569
  direct_ins_str = ""
2503
2570
 
2504
2571
  if self.script_data_in_has_files:
2505
2572
  # need to pass "_input_files" keyword argument to script main function:
2506
- input_files_str = dedent(
2507
- """\
2573
+ input_files_str = """
2508
2574
  inp_files = {}
2509
2575
  if args.inputs_json:
2510
2576
  inp_files["json"] = Path(args.inputs_json)
2511
2577
  if args.inputs_hdf5:
2512
2578
  inp_files["hdf5"] = Path(args.inputs_hdf5)
2513
2579
  """
2514
- )
2515
- input_files_arg_str = "_input_files=inp_files"
2516
- func_kwargs_lst.append(input_files_arg_str)
2580
+ func_kwargs_lst.append("_input_files=inp_files")
2517
2581
  else:
2518
2582
  input_files_str = ""
2519
2583
 
2520
2584
  if self.script_data_out_has_files:
2521
2585
  # need to pass "_output_files" keyword argument to script main function:
2522
- output_files_str = dedent(
2523
- """\
2586
+ output_files_str = """
2524
2587
  out_files = {}
2525
2588
  if args.outputs_json:
2526
2589
  out_files["json"] = Path(args.outputs_json)
2527
2590
  if args.outputs_hdf5:
2528
2591
  out_files["hdf5"] = Path(args.outputs_hdf5)
2529
2592
  """
2530
- )
2531
- output_files_arg_str = "_output_files=out_files"
2532
- func_kwargs_lst.append(output_files_arg_str)
2593
+ func_kwargs_lst.append("_output_files=out_files")
2533
2594
 
2534
2595
  else:
2535
2596
  output_files_str = ""
@@ -2538,13 +2599,11 @@ class Action(JSONLike):
2538
2599
  func_invoke_str = f"{script_main_func}({', '.join(func_kwargs_lst)})"
2539
2600
  if "direct" in self.script_data_out_grouped:
2540
2601
  py_main_block_invoke = f"outputs = {func_invoke_str}"
2541
- py_main_block_outputs = dedent(
2542
- """\
2602
+ py_main_block_outputs = """
2543
2603
  outputs = {"outputs." + k: v for k, v in outputs.items()}
2544
2604
  for name_i, out_i in outputs.items():
2545
2605
  wk.set_parameter_value(param_id=EAR.data_idx[name_i], value=out_i)
2546
- """
2547
- )
2606
+ """
2548
2607
  else:
2549
2608
  py_main_block_invoke = func_invoke_str
2550
2609
  py_main_block_outputs = ""
@@ -2562,13 +2621,13 @@ class Action(JSONLike):
2562
2621
  {outputs}
2563
2622
  """
2564
2623
  ).format(
2565
- py_imports=indent(py_imports, tab_indent),
2566
- wk_load=indent(py_main_block_workflow_load, tab_indent),
2624
+ py_imports=indent(dedent(py_imports), tab_indent),
2625
+ wk_load=indent(dedent(py_main_block_workflow_load), tab_indent),
2567
2626
  direct_ins=indent(direct_ins_str, tab_indent),
2568
- in_files=indent(input_files_str, tab_indent),
2569
- out_files=indent(output_files_str, tab_indent),
2627
+ in_files=indent(dedent(input_files_str), tab_indent),
2628
+ out_files=indent(dedent(output_files_str), tab_indent),
2570
2629
  invoke=indent(py_main_block_invoke, tab_indent),
2571
- outputs=indent(py_main_block_outputs, tab_indent),
2630
+ outputs=indent(dedent(py_main_block_outputs), tab_indent),
2572
2631
  )
2573
2632
 
2574
2633
  out = dedent(
@@ -2583,7 +2642,7 @@ class Action(JSONLike):
2583
2642
 
2584
2643
  return out
2585
2644
 
2586
- def get_parameter_names(self, prefix: str) -> List[str]:
2645
+ def get_parameter_names(self, prefix: str) -> list[str]:
2587
2646
  """Get parameter types associated with a given prefix.
2588
2647
 
2589
2648
  For example, with the prefix "inputs", this would return `['p1', 'p2']` for an
@@ -2604,11 +2663,12 @@ class Action(JSONLike):
2604
2663
  """
2605
2664
  if prefix == "inputs":
2606
2665
  single_lab_lookup = self.task_schema._get_single_label_lookup()
2607
- out = list(single_lab_lookup.get(i, i) for i in self.get_input_types())
2666
+ return [single_lab_lookup.get(i, i) for i in self.get_input_types()]
2608
2667
  elif prefix == "outputs":
2609
- out = list(f"{i}" for i in self.get_output_types())
2668
+ return list(self.get_output_types())
2610
2669
  elif prefix == "input_files":
2611
- out = list(f"{i}" for i in self.get_input_file_labels())
2670
+ return list(self.get_input_file_labels())
2612
2671
  elif prefix == "output_files":
2613
- out = list(f"{i}" for i in self.get_output_file_labels())
2614
- return out
2672
+ return list(self.get_output_file_labels())
2673
+ else:
2674
+ raise ValueError(f"unexpected prefix: {prefix}")