hpcflow-new2 0.2.0a189__py3-none-any.whl → 0.2.0a199__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 (176) hide show
  1. hpcflow/__pyinstaller/hook-hpcflow.py +9 -6
  2. hpcflow/_version.py +1 -1
  3. hpcflow/app.py +1 -0
  4. hpcflow/data/scripts/bad_script.py +2 -0
  5. hpcflow/data/scripts/do_nothing.py +2 -0
  6. hpcflow/data/scripts/env_specifier_test/input_file_generator_pass_env_spec.py +4 -0
  7. hpcflow/data/scripts/env_specifier_test/main_script_test_pass_env_spec.py +8 -0
  8. hpcflow/data/scripts/env_specifier_test/output_file_parser_pass_env_spec.py +4 -0
  9. hpcflow/data/scripts/env_specifier_test/v1/input_file_generator_basic.py +4 -0
  10. hpcflow/data/scripts/env_specifier_test/v1/main_script_test_direct_in_direct_out.py +7 -0
  11. hpcflow/data/scripts/env_specifier_test/v1/output_file_parser_basic.py +4 -0
  12. hpcflow/data/scripts/env_specifier_test/v2/main_script_test_direct_in_direct_out.py +7 -0
  13. hpcflow/data/scripts/input_file_generator_basic.py +3 -0
  14. hpcflow/data/scripts/input_file_generator_basic_FAIL.py +3 -0
  15. hpcflow/data/scripts/input_file_generator_test_stdout_stderr.py +8 -0
  16. hpcflow/data/scripts/main_script_test_direct_in.py +3 -0
  17. hpcflow/data/scripts/main_script_test_direct_in_direct_out_2.py +6 -0
  18. hpcflow/data/scripts/main_script_test_direct_in_direct_out_2_fail_allowed.py +6 -0
  19. hpcflow/data/scripts/main_script_test_direct_in_direct_out_2_fail_allowed_group.py +7 -0
  20. hpcflow/data/scripts/main_script_test_direct_in_direct_out_3.py +6 -0
  21. hpcflow/data/scripts/main_script_test_direct_in_group_direct_out_3.py +6 -0
  22. hpcflow/data/scripts/main_script_test_direct_in_group_one_fail_direct_out_3.py +6 -0
  23. hpcflow/data/scripts/main_script_test_hdf5_in_obj.py +1 -1
  24. hpcflow/data/scripts/main_script_test_hdf5_in_obj_2.py +12 -0
  25. hpcflow/data/scripts/main_script_test_hdf5_out_obj.py +1 -1
  26. hpcflow/data/scripts/main_script_test_json_out_FAIL.py +3 -0
  27. hpcflow/data/scripts/main_script_test_shell_env_vars.py +12 -0
  28. hpcflow/data/scripts/main_script_test_std_out_std_err.py +6 -0
  29. hpcflow/data/scripts/output_file_parser_basic.py +3 -0
  30. hpcflow/data/scripts/output_file_parser_basic_FAIL.py +7 -0
  31. hpcflow/data/scripts/output_file_parser_test_stdout_stderr.py +8 -0
  32. hpcflow/data/scripts/script_exit_test.py +5 -0
  33. hpcflow/data/template_components/environments.yaml +1 -1
  34. hpcflow/sdk/__init__.py +26 -15
  35. hpcflow/sdk/app.py +2192 -768
  36. hpcflow/sdk/cli.py +506 -296
  37. hpcflow/sdk/cli_common.py +105 -7
  38. hpcflow/sdk/config/__init__.py +1 -1
  39. hpcflow/sdk/config/callbacks.py +115 -43
  40. hpcflow/sdk/config/cli.py +126 -103
  41. hpcflow/sdk/config/config.py +674 -318
  42. hpcflow/sdk/config/config_file.py +131 -95
  43. hpcflow/sdk/config/errors.py +125 -84
  44. hpcflow/sdk/config/types.py +148 -0
  45. hpcflow/sdk/core/__init__.py +25 -1
  46. hpcflow/sdk/core/actions.py +1771 -1059
  47. hpcflow/sdk/core/app_aware.py +24 -0
  48. hpcflow/sdk/core/cache.py +139 -79
  49. hpcflow/sdk/core/command_files.py +263 -287
  50. hpcflow/sdk/core/commands.py +145 -112
  51. hpcflow/sdk/core/element.py +828 -535
  52. hpcflow/sdk/core/enums.py +192 -0
  53. hpcflow/sdk/core/environment.py +74 -93
  54. hpcflow/sdk/core/errors.py +455 -52
  55. hpcflow/sdk/core/execute.py +207 -0
  56. hpcflow/sdk/core/json_like.py +540 -272
  57. hpcflow/sdk/core/loop.py +751 -347
  58. hpcflow/sdk/core/loop_cache.py +164 -47
  59. hpcflow/sdk/core/object_list.py +370 -207
  60. hpcflow/sdk/core/parameters.py +1100 -627
  61. hpcflow/sdk/core/rule.py +59 -41
  62. hpcflow/sdk/core/run_dir_files.py +21 -37
  63. hpcflow/sdk/core/skip_reason.py +7 -0
  64. hpcflow/sdk/core/task.py +1649 -1339
  65. hpcflow/sdk/core/task_schema.py +308 -196
  66. hpcflow/sdk/core/test_utils.py +191 -114
  67. hpcflow/sdk/core/types.py +440 -0
  68. hpcflow/sdk/core/utils.py +485 -309
  69. hpcflow/sdk/core/validation.py +82 -9
  70. hpcflow/sdk/core/workflow.py +2544 -1178
  71. hpcflow/sdk/core/zarr_io.py +98 -137
  72. hpcflow/sdk/data/workflow_spec_schema.yaml +2 -0
  73. hpcflow/sdk/demo/cli.py +53 -33
  74. hpcflow/sdk/helper/cli.py +18 -15
  75. hpcflow/sdk/helper/helper.py +75 -63
  76. hpcflow/sdk/helper/watcher.py +61 -28
  77. hpcflow/sdk/log.py +122 -71
  78. hpcflow/sdk/persistence/__init__.py +8 -31
  79. hpcflow/sdk/persistence/base.py +1360 -606
  80. hpcflow/sdk/persistence/defaults.py +6 -0
  81. hpcflow/sdk/persistence/discovery.py +38 -0
  82. hpcflow/sdk/persistence/json.py +568 -188
  83. hpcflow/sdk/persistence/pending.py +382 -179
  84. hpcflow/sdk/persistence/store_resource.py +39 -23
  85. hpcflow/sdk/persistence/types.py +318 -0
  86. hpcflow/sdk/persistence/utils.py +14 -11
  87. hpcflow/sdk/persistence/zarr.py +1337 -433
  88. hpcflow/sdk/runtime.py +44 -41
  89. hpcflow/sdk/submission/{jobscript_info.py → enums.py} +39 -12
  90. hpcflow/sdk/submission/jobscript.py +1651 -692
  91. hpcflow/sdk/submission/schedulers/__init__.py +167 -39
  92. hpcflow/sdk/submission/schedulers/direct.py +121 -81
  93. hpcflow/sdk/submission/schedulers/sge.py +170 -129
  94. hpcflow/sdk/submission/schedulers/slurm.py +291 -268
  95. hpcflow/sdk/submission/schedulers/utils.py +12 -2
  96. hpcflow/sdk/submission/shells/__init__.py +14 -15
  97. hpcflow/sdk/submission/shells/base.py +150 -29
  98. hpcflow/sdk/submission/shells/bash.py +283 -173
  99. hpcflow/sdk/submission/shells/os_version.py +31 -30
  100. hpcflow/sdk/submission/shells/powershell.py +228 -170
  101. hpcflow/sdk/submission/submission.py +1014 -335
  102. hpcflow/sdk/submission/types.py +140 -0
  103. hpcflow/sdk/typing.py +182 -12
  104. hpcflow/sdk/utils/arrays.py +71 -0
  105. hpcflow/sdk/utils/deferred_file.py +55 -0
  106. hpcflow/sdk/utils/hashing.py +16 -0
  107. hpcflow/sdk/utils/patches.py +12 -0
  108. hpcflow/sdk/utils/strings.py +33 -0
  109. hpcflow/tests/api/test_api.py +32 -0
  110. hpcflow/tests/conftest.py +27 -6
  111. hpcflow/tests/data/multi_path_sequences.yaml +29 -0
  112. hpcflow/tests/data/workflow_test_run_abort.yaml +34 -35
  113. hpcflow/tests/schedulers/sge/test_sge_submission.py +36 -0
  114. hpcflow/tests/schedulers/slurm/test_slurm_submission.py +5 -2
  115. hpcflow/tests/scripts/test_input_file_generators.py +282 -0
  116. hpcflow/tests/scripts/test_main_scripts.py +866 -85
  117. hpcflow/tests/scripts/test_non_snippet_script.py +46 -0
  118. hpcflow/tests/scripts/test_ouput_file_parsers.py +353 -0
  119. hpcflow/tests/shells/wsl/test_wsl_submission.py +12 -4
  120. hpcflow/tests/unit/test_action.py +262 -75
  121. hpcflow/tests/unit/test_action_rule.py +9 -4
  122. hpcflow/tests/unit/test_app.py +33 -6
  123. hpcflow/tests/unit/test_cache.py +46 -0
  124. hpcflow/tests/unit/test_cli.py +134 -1
  125. hpcflow/tests/unit/test_command.py +71 -54
  126. hpcflow/tests/unit/test_config.py +142 -16
  127. hpcflow/tests/unit/test_config_file.py +21 -18
  128. hpcflow/tests/unit/test_element.py +58 -62
  129. hpcflow/tests/unit/test_element_iteration.py +50 -1
  130. hpcflow/tests/unit/test_element_set.py +29 -19
  131. hpcflow/tests/unit/test_group.py +4 -2
  132. hpcflow/tests/unit/test_input_source.py +116 -93
  133. hpcflow/tests/unit/test_input_value.py +29 -24
  134. hpcflow/tests/unit/test_jobscript_unit.py +757 -0
  135. hpcflow/tests/unit/test_json_like.py +44 -35
  136. hpcflow/tests/unit/test_loop.py +1396 -84
  137. hpcflow/tests/unit/test_meta_task.py +325 -0
  138. hpcflow/tests/unit/test_multi_path_sequences.py +229 -0
  139. hpcflow/tests/unit/test_object_list.py +17 -12
  140. hpcflow/tests/unit/test_parameter.py +29 -7
  141. hpcflow/tests/unit/test_persistence.py +237 -42
  142. hpcflow/tests/unit/test_resources.py +20 -18
  143. hpcflow/tests/unit/test_run.py +117 -6
  144. hpcflow/tests/unit/test_run_directories.py +29 -0
  145. hpcflow/tests/unit/test_runtime.py +2 -1
  146. hpcflow/tests/unit/test_schema_input.py +23 -15
  147. hpcflow/tests/unit/test_shell.py +23 -2
  148. hpcflow/tests/unit/test_slurm.py +8 -7
  149. hpcflow/tests/unit/test_submission.py +38 -89
  150. hpcflow/tests/unit/test_task.py +352 -247
  151. hpcflow/tests/unit/test_task_schema.py +33 -20
  152. hpcflow/tests/unit/test_utils.py +9 -11
  153. hpcflow/tests/unit/test_value_sequence.py +15 -12
  154. hpcflow/tests/unit/test_workflow.py +114 -83
  155. hpcflow/tests/unit/test_workflow_template.py +0 -1
  156. hpcflow/tests/unit/utils/test_arrays.py +40 -0
  157. hpcflow/tests/unit/utils/test_deferred_file_writer.py +34 -0
  158. hpcflow/tests/unit/utils/test_hashing.py +65 -0
  159. hpcflow/tests/unit/utils/test_patches.py +5 -0
  160. hpcflow/tests/unit/utils/test_redirect_std.py +50 -0
  161. hpcflow/tests/workflows/__init__.py +0 -0
  162. hpcflow/tests/workflows/test_directory_structure.py +31 -0
  163. hpcflow/tests/workflows/test_jobscript.py +334 -1
  164. hpcflow/tests/workflows/test_run_status.py +198 -0
  165. hpcflow/tests/workflows/test_skip_downstream.py +696 -0
  166. hpcflow/tests/workflows/test_submission.py +140 -0
  167. hpcflow/tests/workflows/test_workflows.py +160 -15
  168. hpcflow/tests/workflows/test_zip.py +18 -0
  169. hpcflow/viz_demo.ipynb +6587 -3
  170. {hpcflow_new2-0.2.0a189.dist-info → hpcflow_new2-0.2.0a199.dist-info}/METADATA +8 -4
  171. hpcflow_new2-0.2.0a199.dist-info/RECORD +221 -0
  172. hpcflow/sdk/core/parallel.py +0 -21
  173. hpcflow_new2-0.2.0a189.dist-info/RECORD +0 -158
  174. {hpcflow_new2-0.2.0a189.dist-info → hpcflow_new2-0.2.0a199.dist-info}/LICENSE +0 -0
  175. {hpcflow_new2-0.2.0a189.dist-info → hpcflow_new2-0.2.0a199.dist-info}/WHEEL +0 -0
  176. {hpcflow_new2-0.2.0a189.dist-info → hpcflow_new2-0.2.0a199.dist-info}/entry_points.txt +0 -0
@@ -3,48 +3,77 @@ Parameters represent information passed around within a workflow.
3
3
  """
4
4
 
5
5
  from __future__ import annotations
6
+ from collections.abc import Sequence
6
7
  import copy
7
8
  from dataclasses import dataclass, field
8
9
  from datetime import timedelta
9
10
  import enum
10
11
  from pathlib import Path
11
12
  import re
12
- from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
13
+ from typing import TypeVar, cast, TYPE_CHECKING
14
+ from typing_extensions import override, TypeIs
13
15
 
14
16
  import numpy as np
15
- import valida
16
-
17
- from hpcflow.sdk import app
18
- from hpcflow.sdk.core.element import ElementFilter
17
+ from scipy.stats.qmc import LatinHypercube
18
+ from valida import Schema as ValidaSchema # type: ignore
19
+
20
+ from hpcflow.sdk.typing import hydrate
21
+ from hpcflow.sdk.core.enums import (
22
+ InputSourceType,
23
+ ParallelMode,
24
+ ParameterPropagationMode,
25
+ TaskSourceType,
26
+ )
19
27
  from hpcflow.sdk.core.errors import (
20
28
  MalformedParameterPathError,
21
29
  UnknownResourceSpecItemError,
22
30
  WorkflowParameterMissingError,
23
31
  )
24
32
  from hpcflow.sdk.core.json_like import ChildObjectSpec, JSONLike
25
- from hpcflow.sdk.core.parallel import ParallelMode
26
- from hpcflow.sdk.core.rule import Rule
27
33
  from hpcflow.sdk.core.utils import (
28
34
  check_valid_py_identifier,
29
35
  get_enum_by_name_or_val,
30
36
  linspace_rect,
31
37
  process_string_nodes,
32
38
  split_param_label,
39
+ timedelta_format,
33
40
  )
34
- from hpcflow.sdk.submission.shells import get_shell
35
- from hpcflow.sdk.submission.submission import timedelta_format
36
41
 
42
+ if TYPE_CHECKING:
43
+ from collections.abc import Iterable, Iterator, Mapping
44
+ from typing import Any, ClassVar, Literal
45
+ from typing_extensions import Self, TypeAlias
46
+ from h5py import Group # type: ignore
47
+ from numpy.typing import NDArray
48
+ from ..app import BaseApp
49
+ from ..typing import ParamSource
50
+ from .actions import ActionScope
51
+ from .element import ElementFilter
52
+ from .object_list import ResourceList
53
+ from .rule import Rule
54
+ from .task import ElementSet, TaskSchema, TaskTemplate, WorkflowTask
55
+ from .types import (
56
+ Address,
57
+ Numeric,
58
+ LabelInfo,
59
+ LabellingDescriptor,
60
+ ResourcePersistingWorkflow,
61
+ RuleArgs,
62
+ SchemaInputKwargs,
63
+ )
64
+ from .workflow import Workflow, WorkflowTemplate
65
+ from .validation import Schema
66
+
67
+
68
+ T = TypeVar("T")
37
69
 
38
- Address = List[Union[int, float, str]]
39
- Numeric = Union[int, float, np.number]
40
70
 
71
+ def _process_demo_data_strings(app: BaseApp, value: T) -> T:
72
+ demo_pattern = re.compile(r"\<\<demo_data_file:(.*)\>\>")
41
73
 
42
- def _process_demo_data_strings(app, value):
43
- def string_processor(str_in):
44
- demo_pattern = r"\<\<demo_data_file:(.*)\>\>"
45
- str_out = re.sub(
46
- pattern=demo_pattern,
47
- repl=lambda x: str(app.get_demo_data_file_path(x.group(1))),
74
+ def string_processor(str_in: str) -> str:
75
+ str_out = demo_pattern.sub(
76
+ repl=lambda x: str(app.get_demo_data_file_path(x[1])),
48
77
  string=str_in,
49
78
  )
50
79
  return str_out
@@ -52,6 +81,8 @@ def _process_demo_data_strings(app, value):
52
81
  return process_string_nodes(value, string_processor)
53
82
 
54
83
 
84
+ @dataclass
85
+ @hydrate
55
86
  class ParameterValue:
56
87
  """
57
88
  The value handler for a parameter.
@@ -59,58 +90,60 @@ class ParameterValue:
59
90
  Intended to be subclassed.
60
91
  """
61
92
 
62
- _typ = None
63
- _sub_parameters = {}
93
+ _typ: ClassVar[str | None] = None
94
+ _sub_parameters: ClassVar[dict[str, str]] = {}
64
95
 
65
- def to_dict(self):
96
+ def to_dict(self) -> dict[str, Any]:
66
97
  """
67
98
  Serialise this parameter value as a dictionary.
68
99
  """
69
100
  if hasattr(self, "__dict__"):
70
- return dict(self.__dict__)
101
+ return self._postprocess_to_dict(dict(self.__dict__))
71
102
  elif hasattr(self, "__slots__"):
72
- return {k: getattr(self, k) for k in self.__slots__}
103
+ return self._postprocess_to_dict(
104
+ {k: getattr(self, k) for k in self.__slots__}
105
+ )
106
+ else:
107
+ raise NotImplementedError
108
+
109
+ def _postprocess_to_dict(self, d: dict[str, Any]) -> dict[str, Any]:
110
+ """Postprocess the results of :meth:`to_dict`."""
111
+ return d
73
112
 
74
- def prepare_JSON_dump(self) -> Dict:
113
+ def prepare_JSON_dump(self) -> dict[str, Any]:
75
114
  """
76
115
  Prepare this parameter value for serialisation as JSON.
77
116
  """
78
117
  raise NotImplementedError
79
118
 
80
- def dump_to_HDF5_group(self, group):
119
+ def dump_to_HDF5_group(self, group: Group):
81
120
  """
82
121
  Write this parameter value to an HDF5 group.
83
122
  """
84
123
  raise NotImplementedError
85
124
 
86
125
  @classmethod
87
- def save_from_HDF5_group(cls, group, param_id: int, workflow):
126
+ def dump_element_group_to_HDF5_group(cls, objs: list[ParameterValue], group: Group):
127
+ """
128
+ Write a list (from an element group) of parameter values to an HDF5 group.
129
+ """
130
+ raise NotImplementedError
131
+
132
+ @classmethod
133
+ def save_from_HDF5_group(cls, group: Group, param_id: int, workflow: Workflow):
88
134
  """
89
135
  Extract a parameter value from an HDF5 group.
90
136
  """
91
137
  raise NotImplementedError
92
138
 
93
139
  @classmethod
94
- def save_from_JSON(cls, data, param_id: int, workflow):
140
+ def save_from_JSON(cls, data, param_id: int | list[int], workflow: Workflow):
95
141
  """
96
142
  Extract a parameter value from JSON data.
97
143
  """
98
144
  raise NotImplementedError
99
145
 
100
146
 
101
- class ParameterPropagationMode(enum.Enum):
102
- """
103
- How a parameter is propagated.
104
- """
105
-
106
- #: Parameter is propagated implicitly.
107
- IMPLICIT = 0
108
- #: Parameter is propagated explicitly.
109
- EXPLICIT = 1
110
- #: Parameter is never propagated.
111
- NEVER = 2
112
-
113
-
114
147
  @dataclass
115
148
  class ParameterPath(JSONLike):
116
149
  """
@@ -119,14 +152,13 @@ class ParameterPath(JSONLike):
119
152
 
120
153
  # TODO: unused?
121
154
  #: The path to the parameter.
122
- path: Sequence[Union[str, int, float]]
155
+ path: Sequence[str | int | float]
123
156
  #: The task in which to look up the parameter.
124
- task: Optional[
125
- Union[app.TaskTemplate, app.TaskSchema]
126
- ] = None # default is "current" task
157
+ task: TaskTemplate | TaskSchema | None = None # default is "current" task
127
158
 
128
159
 
129
160
  @dataclass
161
+ @hydrate
130
162
  class Parameter(JSONLike):
131
163
  """
132
164
  A general parameter to a workflow task.
@@ -150,15 +182,15 @@ class Parameter(JSONLike):
150
182
  Validation schema.
151
183
  """
152
184
 
153
- _validation_schema = "parameters_spec_schema.yaml"
154
- _child_objects = (
185
+ _validation_schema: ClassVar[str] = "parameters_spec_schema.yaml"
186
+ _child_objects: ClassVar[tuple[ChildObjectSpec, ...]] = (
155
187
  ChildObjectSpec(
156
188
  name="typ",
157
189
  json_like_name="type",
158
190
  ),
159
191
  ChildObjectSpec(
160
192
  name="_validation",
161
- class_obj=valida.Schema,
193
+ class_obj=ValidaSchema,
162
194
  ),
163
195
  )
164
196
 
@@ -168,10 +200,10 @@ class Parameter(JSONLike):
168
200
  #: Whether this parameter represents a file.
169
201
  is_file: bool = False
170
202
  #: Any parameters packed within this one.
171
- sub_parameters: List[app.SubParameter] = field(default_factory=lambda: [])
172
- _value_class: Any = None
173
- _hash_value: Optional[str] = field(default=None, repr=False)
174
- _validation: Optional[valida.Schema] = None
203
+ sub_parameters: list[SubParameter] = field(default_factory=list)
204
+ _value_class: type[ParameterValue] | None = None
205
+ _hash_value: str | None = field(default=None, repr=False)
206
+ _validation: Schema | None = None
175
207
 
176
208
  def __repr__(self) -> str:
177
209
  is_file_str = ""
@@ -192,31 +224,42 @@ class Parameter(JSONLike):
192
224
  f")"
193
225
  )
194
226
 
195
- def __post_init__(self):
227
+ def __post_init__(self) -> None:
196
228
  self.typ = check_valid_py_identifier(self.typ)
197
229
  self._set_value_class()
198
230
 
199
- def _set_value_class(self):
231
+ def _set_value_class(self) -> None:
200
232
  # custom parameter classes must inherit from `ParameterValue` not the app
201
233
  # subclass:
202
234
  if self._value_class is None:
203
- for i in ParameterValue.__subclasses__():
204
- if i._typ == self.typ:
205
- self._value_class = i
235
+ self._value_class = next(
236
+ (
237
+ pv_class
238
+ for pv_class in ParameterValue.__subclasses__()
239
+ if pv_class._typ == self.typ
240
+ ),
241
+ None,
242
+ )
206
243
 
207
- def __lt__(self, other):
244
+ def __eq__(self, other: Any) -> bool:
245
+ return isinstance(other, self.__class__) and self.typ == other.typ
246
+
247
+ def __lt__(self, other: Parameter):
208
248
  return self.typ < other.typ
209
249
 
210
- def __deepcopy__(self, memo):
250
+ def __deepcopy__(self, memo: dict[int, Any]):
211
251
  kwargs = self.to_dict()
212
252
  _validation = kwargs.pop("_validation")
213
253
  obj = self.__class__(**copy.deepcopy(kwargs, memo))
214
254
  obj._validation = _validation
215
255
  return obj
216
256
 
217
- def to_dict(self):
218
- dct = super().to_dict()
257
+ @override
258
+ def _postprocess_to_dict(self, d: dict[str, Any]) -> dict[str, Any]:
259
+ dct = super()._postprocess_to_dict(d)
219
260
  del dct["_value_class"]
261
+ if dct.get("name", None) is None:
262
+ dct.pop("name", None)
220
263
  dct.pop("_task_schema", None) # TODO: how do we have a _task_schema ref?
221
264
  return dct
222
265
 
@@ -227,6 +270,25 @@ class Parameter(JSONLike):
227
270
  """
228
271
  return self.typ.lower().replace("_", "-")
229
272
 
273
+ def _instantiate_value(self, source: ParamSource, val: dict) -> Any:
274
+ """
275
+ Convert the serialized form of this parameter to its "real" form,
276
+ if that is valid to do at all.
277
+ """
278
+ if self._value_class is None:
279
+ return val
280
+ if (method_name := source.get("value_class_method")) is not None:
281
+ method = getattr(self._value_class, method_name)
282
+ else:
283
+ method = self._value_class
284
+ return method(**val)
285
+
286
+ def _force_value_class(self) -> type[ParameterValue] | None:
287
+ if (param_cls := self._value_class) is None:
288
+ self._set_value_class()
289
+ param_cls = self._value_class
290
+ return param_cls
291
+
230
292
 
231
293
  @dataclass
232
294
  class SubParameter:
@@ -237,10 +299,11 @@ class SubParameter:
237
299
  #: How to find this within the containing paraneter.
238
300
  address: Address
239
301
  #: The containing main parameter.
240
- parameter: app.Parameter
302
+ parameter: Parameter
241
303
 
242
304
 
243
305
  @dataclass
306
+ @hydrate
244
307
  class SchemaParameter(JSONLike):
245
308
  """
246
309
  A parameter bound in a schema.
@@ -251,9 +314,7 @@ class SchemaParameter(JSONLike):
251
314
  The parameter.
252
315
  """
253
316
 
254
- _app_attr = "app"
255
-
256
- _child_objects = (
317
+ _child_objects: ClassVar[tuple[ChildObjectSpec, ...]] = (
257
318
  ChildObjectSpec(
258
319
  name="parameter",
259
320
  class_name="Parameter",
@@ -262,22 +323,15 @@ class SchemaParameter(JSONLike):
262
323
  ),
263
324
  )
264
325
 
265
- def __post_init__(self):
326
+ def __post_init__(self) -> None:
266
327
  self._validate()
267
328
 
268
- def _validate(self):
329
+ def _validate(self) -> None:
269
330
  if isinstance(self.parameter, str):
270
- self.parameter = self.app.Parameter(self.parameter)
271
-
272
- @property
273
- def name(self):
274
- """
275
- The name of the parameter.
276
- """
277
- return self.parameter.name
331
+ self.parameter: Parameter = self._app.Parameter(typ=self.parameter)
278
332
 
279
333
  @property
280
- def typ(self):
334
+ def typ(self) -> str:
281
335
  """
282
336
  The type code of the parameter.
283
337
  """
@@ -294,6 +348,7 @@ class NullDefault(enum.Enum):
294
348
  NULL = 0
295
349
 
296
350
 
351
+ @hydrate
297
352
  class SchemaInput(SchemaParameter):
298
353
  """A Parameter as used within a particular schema, for which a default value may be
299
354
  applied.
@@ -330,11 +385,18 @@ class SchemaInput(SchemaParameter):
330
385
  Determines the name of the element group from which this input should be sourced.
331
386
  This is a default value that will be applied to all `labels` if a "group" key
332
387
  does not exist.
388
+ allow_failed_dependencies
389
+ This controls whether failure to retrieve inputs (i.e. an
390
+ `UnsetParameterDataError` is raised for one of the input sources) should be
391
+ allowed. By default, the unset value, which is equivalent to `False`, means no
392
+ failures are allowed. If set to `True`, any number of failures are allowed. If an
393
+ integer is specified, that number of failures are permitted. Finally, if a float
394
+ is specified, that proportion of failures are allowed.
333
395
  """
334
396
 
335
- _task_schema = None # assigned by parent TaskSchema
397
+ _task_schema: TaskSchema | None = None # assigned by parent TaskSchema
336
398
 
337
- _child_objects = (
399
+ _child_objects: ClassVar[tuple[ChildObjectSpec, ...]] = (
338
400
  ChildObjectSpec(
339
401
  name="parameter",
340
402
  class_name="Parameter",
@@ -345,12 +407,13 @@ class SchemaInput(SchemaParameter):
345
407
 
346
408
  def __init__(
347
409
  self,
348
- parameter: app.Parameter,
410
+ parameter: Parameter | str,
349
411
  multiple: bool = False,
350
- labels: Optional[Dict] = None,
351
- default_value: Optional[Union[app.InputValue, NullDefault]] = NullDefault.NULL,
412
+ labels: dict[str, LabelInfo] | None = None,
413
+ default_value: InputValue | Any | NullDefault = NullDefault.NULL,
352
414
  propagation_mode: ParameterPropagationMode = ParameterPropagationMode.IMPLICIT,
353
- group: Optional[str] = None,
415
+ group: str | None = None,
416
+ allow_failed_dependencies: int | float | bool | None = False,
354
417
  ):
355
418
  # TODO: can we define elements groups on local inputs as well, or should these be
356
419
  # just for elements from other tasks?
@@ -360,24 +423,32 @@ class SchemaInput(SchemaParameter):
360
423
 
361
424
  if isinstance(parameter, str):
362
425
  try:
363
- parameter = self.app.parameters.get(parameter)
426
+ #: The parameter (i.e. type) of this schema input.
427
+ self.parameter = self._app.parameters.get(parameter)
364
428
  except ValueError:
365
- parameter = self.app.Parameter(parameter)
429
+ self.parameter = self._app.Parameter(parameter)
430
+ else:
431
+ self.parameter = parameter
366
432
 
367
- #: The parameter (i.e. type) of this schema input.
368
- self.parameter = parameter
369
- #: Whether to expect more than of these parameters defined in the workflow.
433
+ if allow_failed_dependencies is None:
434
+ allow_failed_dependencies = 0.0
435
+ elif isinstance(allow_failed_dependencies, bool):
436
+ allow_failed_dependencies = float(allow_failed_dependencies)
437
+
438
+ #: Whether to expect multiple labels for this parameter.
370
439
  self.multiple = multiple
440
+ self.allow_failed_dependencies = allow_failed_dependencies
441
+
371
442
  #: Dict whose keys represent the string labels that distinguish multiple
372
443
  #: parameters if `multiple` is `True`.
373
- self.labels = labels
374
-
375
- if self.labels is None:
444
+ self.labels: dict[str, LabelInfo]
445
+ if labels is None:
376
446
  if self.multiple:
377
447
  self.labels = {"*": {}}
378
448
  else:
379
449
  self.labels = {"": {}}
380
450
  else:
451
+ self.labels = labels
381
452
  if not self.multiple:
382
453
  # check single-item:
383
454
  if len(self.labels) > 1:
@@ -387,7 +458,7 @@ class SchemaInput(SchemaParameter):
387
458
  f"`labels` is: {self.labels!r}."
388
459
  )
389
460
 
390
- labels_defaults = {}
461
+ labels_defaults: LabelInfo = {}
391
462
  if propagation_mode is not None:
392
463
  labels_defaults["propagation_mode"] = propagation_mode
393
464
  if group is not None:
@@ -397,14 +468,15 @@ class SchemaInput(SchemaParameter):
397
468
  for k, v in self.labels.items():
398
469
  labels_defaults_i = copy.deepcopy(labels_defaults)
399
470
  if default_value is not NullDefault.NULL:
400
- if not isinstance(default_value, InputValue):
401
- default_value = app.InputValue(
471
+ if isinstance(default_value, InputValue):
472
+ labels_defaults_i["default_value"] = default_value
473
+ else:
474
+ labels_defaults_i["default_value"] = self._app.InputValue(
402
475
  parameter=self.parameter,
403
476
  value=default_value,
404
477
  label=k,
405
478
  )
406
- labels_defaults_i["default_value"] = default_value
407
- label_i = {**labels_defaults_i, **v}
479
+ label_i: LabelInfo = {**labels_defaults_i, **v}
408
480
  if "propagation_mode" in label_i:
409
481
  label_i["propagation_mode"] = get_enum_by_name_or_val(
410
482
  ParameterPropagationMode, label_i["propagation_mode"]
@@ -420,8 +492,8 @@ class SchemaInput(SchemaParameter):
420
492
  default_str = ""
421
493
  group_str = ""
422
494
  labels_str = ""
423
- if not self.multiple:
424
- label = next(iter(self.labels.keys())) # the single key
495
+ if not self.multiple and self.labels:
496
+ label = next(iter(self.labels)) # the single key
425
497
 
426
498
  default_str = ""
427
499
  if "default_value" in self.labels[label]:
@@ -429,8 +501,7 @@ class SchemaInput(SchemaParameter):
429
501
  f", default_value={self.labels[label]['default_value'].value!r}"
430
502
  )
431
503
 
432
- group = self.labels[label].get("group")
433
- if group is not None:
504
+ if (group := self.labels[label].get("group")) is not None:
434
505
  group_str = f", group={group!r}"
435
506
 
436
507
  else:
@@ -444,20 +515,20 @@ class SchemaInput(SchemaParameter):
444
515
  f")"
445
516
  )
446
517
 
447
- def to_dict(self):
448
- dct = super().to_dict()
518
+ @override
519
+ def _postprocess_to_dict(self, d: dict[str, Any]) -> dict[str, Any]:
520
+ dct = super()._postprocess_to_dict(d)
521
+ v: dict[str, ParameterPropagationMode]
449
522
  for k, v in dct["labels"].items():
450
- prop_mode = v.get("parameter_propagation_mode")
451
- if prop_mode:
523
+ if (prop_mode := v.get("parameter_propagation_mode")) is not None:
452
524
  dct["labels"][k]["parameter_propagation_mode"] = prop_mode.name
453
525
  return dct
454
526
 
455
- def to_json_like(self, dct=None, shared_data=None, exclude=None, path=None):
456
- out, shared = super().to_json_like(dct, shared_data, exclude, path)
457
- for k, v in out["labels"].items():
527
+ def _postprocess_to_json(self, json_like):
528
+ for v in json_like["labels"].values():
458
529
  if "default_value" in v:
459
- out["labels"][k]["default_value_is_input_value"] = True
460
- return out, shared
530
+ v["default_value_is_input_value"] = True
531
+ return json_like
461
532
 
462
533
  @classmethod
463
534
  def from_json_like(cls, json_like, shared_data=None):
@@ -473,81 +544,86 @@ class SchemaInput(SchemaParameter):
473
544
  }
474
545
  json_like["labels"][k][
475
546
  "default_value"
476
- ] = cls.app.InputValue.from_json_like(
547
+ ] = cls._app.InputValue.from_json_like(
477
548
  json_like=inp_val_kwargs,
478
549
  shared_data=shared_data,
479
550
  )
480
551
 
481
- obj = super().from_json_like(json_like, shared_data)
482
- return obj
552
+ return super().from_json_like(json_like, shared_data)
483
553
 
484
- def __deepcopy__(self, memo):
485
- kwargs = {
486
- "parameter": self.parameter,
554
+ def __deepcopy__(self, memo: dict[int, Any]):
555
+ kwargs: SchemaInputKwargs = {
556
+ "parameter": copy.deepcopy(self.parameter, memo),
487
557
  "multiple": self.multiple,
488
- "labels": self.labels,
558
+ "labels": copy.deepcopy(self.labels, memo),
559
+ "allow_failed_dependencies": self.allow_failed_dependencies,
489
560
  }
490
- obj = self.__class__(**copy.deepcopy(kwargs, memo))
561
+ obj = self.__class__(**kwargs)
491
562
  obj._task_schema = self._task_schema
492
563
  return obj
493
564
 
494
565
  @property
495
- def default_value(self):
566
+ def default_value(self) -> InputValue | Literal[NullDefault.NULL] | None:
496
567
  """
497
568
  The default value of the input.
498
569
  """
499
- if not self.multiple:
500
- if "default_value" in self.single_labelled_data:
501
- return self.single_labelled_data["default_value"]
570
+ if single_data := self.single_labelled_data:
571
+ if "default_value" in single_data:
572
+ return single_data["default_value"]
502
573
  else:
503
574
  return NullDefault.NULL
575
+ return None
504
576
 
505
577
  @property
506
- def task_schema(self):
578
+ def task_schema(self) -> TaskSchema:
507
579
  """
508
580
  The schema containing this input.
509
581
  """
582
+ assert self._task_schema is not None
510
583
  return self._task_schema
511
584
 
512
585
  @property
513
- def all_labelled_types(self):
586
+ def all_labelled_types(self) -> list[str]:
514
587
  """
515
588
  The types of the input labels.
516
589
  """
517
- return list(f"{self.typ}{f'[{i}]' if i else ''}" for i in self.labels)
590
+ return [(f"{self.typ}[{i}]" if i else self.typ) for i in self.labels]
518
591
 
519
592
  @property
520
- def single_label(self):
593
+ def single_label(self) -> str | None:
521
594
  """
522
595
  The label of this input, assuming it is not mulitple.
523
596
  """
524
597
  if not self.multiple:
525
598
  return next(iter(self.labels))
599
+ return None
526
600
 
527
601
  @property
528
- def single_labelled_type(self):
602
+ def single_labelled_type(self) -> str | None:
529
603
  """
530
604
  The type code of this input, assuming it is not mulitple.
531
605
  """
532
606
  if not self.multiple:
533
607
  return next(iter(self.labelled_info()))["labelled_type"]
608
+ return None
534
609
 
535
610
  @property
536
- def single_labelled_data(self):
611
+ def single_labelled_data(self) -> LabelInfo | None:
537
612
  """
538
613
  The value of this input, assuming it is not mulitple.
539
614
  """
540
- if not self.multiple:
541
- return self.labels[self.single_label]
615
+ if (label := self.single_label) is not None:
616
+ return self.labels[label]
617
+ return None
542
618
 
543
- def labelled_info(self):
619
+ def labelled_info(self) -> Iterator[LabellingDescriptor]:
544
620
  """
545
621
  Get descriptors for all the labels associated with this input.
546
622
  """
547
623
  for k, v in self.labels.items():
548
- label = f"[{k}]" if k else ""
549
- dct = {
550
- "labelled_type": self.parameter.typ + label,
624
+ label = f"{self.parameter.typ}[{k}]" if k else self.parameter.typ
625
+ dct: LabellingDescriptor = {
626
+ "labelled_type": label,
551
627
  "propagation_mode": v["propagation_mode"],
552
628
  "group": v.get("group"),
553
629
  }
@@ -555,18 +631,28 @@ class SchemaInput(SchemaParameter):
555
631
  dct["default_value"] = v["default_value"]
556
632
  yield dct
557
633
 
558
- def _validate(self):
634
+ @property
635
+ def _simple_labelled_info(self) -> Iterator[tuple[str, ParameterPropagationMode]]:
636
+ """
637
+ Cut-down version of :py:meth:`labelled_info` that has lower overheads.
638
+ """
639
+ for k, v in self.labels.items():
640
+ label = f"{self.parameter.typ}[{k}]" if k else self.parameter.typ
641
+ yield label, v["propagation_mode"]
642
+
643
+ def _validate(self) -> None:
559
644
  super()._validate()
560
645
  for k, v in self.labels.items():
561
646
  if "default_value" in v:
562
647
  if not isinstance(v["default_value"], InputValue):
563
- def_val = self.app.InputValue(
648
+ def_val = self._app.InputValue(
564
649
  parameter=self.parameter,
565
650
  value=v["default_value"],
566
651
  label=k,
567
652
  )
568
- self.labels[k]["default_value"] = def_val
569
- def_val = self.labels[k]["default_value"]
653
+ v["default_value"] = def_val
654
+ else:
655
+ def_val = v["default_value"]
570
656
  if def_val.parameter != self.parameter or def_val.label != k:
571
657
  raise ValueError(
572
658
  f"{self.__class__.__name__} `default_value` for label {k!r} must "
@@ -576,24 +662,36 @@ class SchemaInput(SchemaParameter):
576
662
  )
577
663
 
578
664
  @property
579
- def input_or_output(self):
665
+ def input_or_output(self) -> str:
580
666
  """
581
667
  Whether this is an input or output. Always ``input``.
582
668
  """
583
669
  return "input"
584
670
 
585
671
 
586
- @dataclass
672
+ @dataclass(init=False)
673
+ @hydrate
587
674
  class SchemaOutput(SchemaParameter):
588
675
  """A Parameter as outputted from particular task."""
589
676
 
590
677
  #: The basic parameter this supplies.
591
678
  parameter: Parameter
592
679
  #: How this output propagates.
593
- propagation_mode: ParameterPropagationMode = ParameterPropagationMode.IMPLICIT
680
+ propagation_mode: ParameterPropagationMode
681
+
682
+ def __init__(
683
+ self,
684
+ parameter: Parameter | str,
685
+ propagation_mode: ParameterPropagationMode = ParameterPropagationMode.IMPLICIT,
686
+ ):
687
+ if isinstance(parameter, str):
688
+ self.parameter: Parameter = self._app.Parameter(typ=parameter)
689
+ else:
690
+ self.parameter = parameter
691
+ self.propagation_mode = propagation_mode
594
692
 
595
693
  @property
596
- def input_or_output(self):
694
+ def input_or_output(self) -> str:
597
695
  """
598
696
  Whether this is an input or output. Always ``output``.
599
697
  """
@@ -624,7 +722,52 @@ class BuiltinSchemaParameter:
624
722
  pass
625
723
 
626
724
 
627
- class ValueSequence(JSONLike):
725
+ class _BaseSequence(JSONLike):
726
+ """
727
+ A base class for shared methods of `ValueSequence` and `MultiPathSequence`.
728
+ """
729
+
730
+ def __eq__(self, other: Any) -> bool:
731
+ if not isinstance(other, self.__class__):
732
+ return False
733
+ return self.to_dict() == other.to_dict()
734
+
735
+ @classmethod
736
+ def from_json_like(cls, json_like, shared_data=None):
737
+ if "path" in json_like: # note: singular
738
+ # only applicable to ValueSequence, although not well-defined/useful anyway,
739
+ # I think.
740
+ if "::" in json_like["path"]:
741
+ path, cls_method = json_like["path"].split("::")
742
+ json_like["path"] = path
743
+ json_like["value_class_method"] = cls_method
744
+
745
+ val_key = next((item for item in json_like if "values" in item), "")
746
+ if "::" in val_key:
747
+ # class method (e.g. `from_range`, `from_file` etc):
748
+ _, method = val_key.split("::")
749
+ _values_method_args = json_like.pop(val_key)
750
+
751
+ if "paths" in json_like: # note: plural
752
+ # only applicable to `MultiPathSequence`, where it is useful to know
753
+ # how many paths we are generating sequences for:
754
+ _values_method_args["paths"] = json_like["paths"]
755
+
756
+ _values_method = f"_values_{method}"
757
+ _values_method_args = _process_demo_data_strings(
758
+ cls._app, _values_method_args
759
+ )
760
+ json_like["values"] = getattr(cls, _values_method)(**_values_method_args)
761
+
762
+ obj = super().from_json_like(json_like, shared_data)
763
+ if "::" in val_key:
764
+ obj._values_method = method
765
+ obj._values_method_args = _values_method_args
766
+
767
+ return obj
768
+
769
+
770
+ class ValueSequence(_BaseSequence):
628
771
  """
629
772
  A sequence of values.
630
773
 
@@ -645,39 +788,43 @@ class ValueSequence(JSONLike):
645
788
  def __init__(
646
789
  self,
647
790
  path: str,
648
- values: List[Any],
649
- nesting_order: Optional[int] = 0,
650
- label: Optional[str] = None,
651
- value_class_method: Optional[str] = None,
791
+ values: Sequence[Any] | None,
792
+ nesting_order: int | float | None = None,
793
+ label: str | int | None = None,
794
+ value_class_method: str | None = None,
652
795
  ):
653
- label = str(label) if label is not None else ""
654
- path, label = self._validate_parameter_path(path, label)
655
-
796
+ path_, label_ = self._validate_parameter_path(path, label)
656
797
  #: The path to this sequence.
657
- self.path = path
798
+ self.path = path_
658
799
  #: The label of this sequence.
659
- self.label = label
800
+ self.label = label_
660
801
  #: The nesting order for this sequence.
661
- self.nesting_order = nesting_order
802
+ self.nesting_order = None if nesting_order is None else float(nesting_order)
662
803
  #: Name of a method used to generate sequence values.
663
804
  self.value_class_method = value_class_method
664
805
 
665
806
  if values is not None:
666
- self._values = [_process_demo_data_strings(self.app, i) for i in values]
807
+ self._values: list[Any] | None = [
808
+ _process_demo_data_strings(self._app, i) for i in values
809
+ ]
810
+ else:
811
+ self._values = None
667
812
 
668
- self._values_group_idx = None
669
- self._values_are_objs = None # assigned initially on `make_persistent`
813
+ self._values_group_idx: list[int] | None = None
814
+ self._values_are_objs: list[
815
+ bool
816
+ ] | None = None # assigned initially on `make_persistent`
670
817
 
671
- self._workflow = None
672
- self._element_set = None # assigned by parent ElementSet
818
+ self._workflow: Workflow | None = None # assigned in `make_persistent`
819
+ self._element_set: ElementSet | None = None # assigned by parent `ElementSet`
673
820
 
674
821
  # assigned if this is an "inputs" sequence in `WorkflowTask._add_element_set`:
675
- self._parameter = None
822
+ self._parameter: Parameter | None = None
676
823
 
677
- self._path_split = None # assigned by property `path_split`
824
+ self._path_split: list[str] | None = None # assigned by property `path_split`
678
825
 
679
- self._values_method = None
680
- self._values_method_args = None
826
+ self._values_method: str | None = None
827
+ self._values_method_args: dict | None = None
681
828
 
682
829
  def __repr__(self):
683
830
  label_str = ""
@@ -698,14 +845,7 @@ class ValueSequence(JSONLike):
698
845
  f")"
699
846
  )
700
847
 
701
- def __eq__(self, other) -> bool:
702
- if not isinstance(other, self.__class__):
703
- return False
704
- if self.to_dict() == other.to_dict():
705
- return True
706
- return False
707
-
708
- def __deepcopy__(self, memo):
848
+ def __deepcopy__(self, memo: dict[int, Any]):
709
849
  kwargs = self.to_dict()
710
850
  kwargs["values"] = kwargs.pop("_values")
711
851
 
@@ -728,95 +868,73 @@ class ValueSequence(JSONLike):
728
868
 
729
869
  return obj
730
870
 
731
- @classmethod
732
- def from_json_like(cls, json_like, shared_data=None):
733
- if "::" in json_like["path"]:
734
- path, cls_method = json_like["path"].split("::")
735
- json_like["path"] = path
736
- json_like["value_class_method"] = cls_method
737
-
738
- val_key = None
739
- for i in json_like:
740
- if "values" in i:
741
- val_key = i
742
- if "::" in val_key:
743
- # class method (e.g. `from_range`, `from_file` etc):
744
- _, method = val_key.split("::")
745
- _values_method_args = json_like.pop(val_key)
746
- _values_method = f"_values_{method}"
747
- _values_method_args = _process_demo_data_strings(cls.app, _values_method_args)
748
- json_like["values"] = getattr(cls, _values_method)(**_values_method_args)
749
-
750
- obj = super().from_json_like(json_like, shared_data)
751
- if "::" in val_key:
752
- obj._values_method = method
753
- obj._values_method_args = _values_method_args
754
-
755
- return obj
756
-
757
871
  @property
758
- def parameter(self):
872
+ def parameter(self) -> Parameter | None:
759
873
  """
760
874
  The parameter this sequence supplies.
761
875
  """
762
876
  return self._parameter
763
877
 
764
878
  @property
765
- def path_split(self):
879
+ def path_split(self) -> Sequence[str]:
766
880
  """
767
- The components of ths path.
881
+ The components of this path.
768
882
  """
769
883
  if self._path_split is None:
770
884
  self._path_split = self.path.split(".")
771
885
  return self._path_split
772
886
 
773
887
  @property
774
- def path_type(self):
888
+ def path_type(self) -> str:
775
889
  """
776
890
  The type of path this is.
777
891
  """
778
892
  return self.path_split[0]
779
893
 
780
894
  @property
781
- def input_type(self):
895
+ def input_type(self) -> str | None:
782
896
  """
783
897
  The type of input sequence this is, if it is one.
784
898
  """
785
899
  if self.path_type == "inputs":
786
900
  return self.path_split[1].replace(self._label_fmt, "")
901
+ return None
787
902
 
788
903
  @property
789
- def input_path(self):
904
+ def input_path(self) -> str | None:
790
905
  """
791
906
  The path of the input sequence this is, if it is one.
792
907
  """
793
908
  if self.path_type == "inputs":
794
909
  return ".".join(self.path_split[2:])
910
+ return None
795
911
 
796
912
  @property
797
- def resource_scope(self):
913
+ def resource_scope(self) -> str | None:
798
914
  """
799
915
  The scope of the resources this is, if it is one.
800
916
  """
801
917
  if self.path_type == "resources":
802
918
  return self.path_split[1]
919
+ return None
803
920
 
804
921
  @property
805
- def is_sub_value(self):
922
+ def is_sub_value(self) -> bool:
806
923
  """True if the values are for a sub part of the parameter."""
807
- return True if self.input_path else False
924
+ return bool(self.input_path)
808
925
 
809
926
  @property
810
- def _label_fmt(self):
927
+ def _label_fmt(self) -> str:
811
928
  return f"[{self.label}]" if self.label else ""
812
929
 
813
930
  @property
814
- def labelled_type(self):
931
+ def labelled_type(self) -> str | None:
815
932
  """
816
933
  The labelled type of input sequence this is, if it is one.
817
934
  """
818
935
  if self.input_type:
819
936
  return f"{self.input_type}{self._label_fmt}"
937
+ return None
820
938
 
821
939
  @classmethod
822
940
  def _json_like_constructor(cls, json_like):
@@ -836,7 +954,9 @@ class ValueSequence(JSONLike):
836
954
  obj._values_method_args = _values_method_args
837
955
  return obj
838
956
 
839
- def _validate_parameter_path(self, path, label):
957
+ def _validate_parameter_path(
958
+ self, path: str, label: str | int | None
959
+ ) -> tuple[str, str | int | None]:
840
960
  """Parse the supplied path and perform basic checks on it.
841
961
 
842
962
  This method also adds the specified `SchemaInput` label to the path and checks for
@@ -852,25 +972,24 @@ class ValueSequence(JSONLike):
852
972
  )
853
973
  path_l = path.lower()
854
974
  path_split = path_l.split(".")
855
- allowed_path_start = ("inputs", "resources", "environments", "env_preset")
856
- if not path_split[0] in allowed_path_start:
975
+ ALLOWED_PATH_START = ("inputs", "resources", "environments", "env_preset")
976
+ if not path_split[0] in ALLOWED_PATH_START:
857
977
  raise MalformedParameterPathError(
858
978
  f"`path` must start with one of: "
859
- f'{", ".join(f"{i!r}" for i in allowed_path_start)}, but given path '
979
+ f'{", ".join(f"{pfx!r}" for pfx in ALLOWED_PATH_START)}, but given path '
860
980
  f"is: {path!r}."
861
981
  )
862
982
 
863
983
  _, label_from_path = split_param_label(path_l)
864
984
 
865
985
  if path_split[0] == "inputs":
866
- if label_arg:
867
- if not label_from_path:
986
+ if label_arg is not None and label_arg != "":
987
+ if label_from_path is None:
868
988
  # add label to path without lower casing any parts:
869
989
  path_split_orig = path.split(".")
870
990
  path_split_orig[1] += f"[{label_arg}]"
871
991
  path = ".".join(path_split_orig)
872
- label = label_arg
873
- elif label_arg != label_from_path:
992
+ elif str(label_arg) != label_from_path:
874
993
  raise ValueError(
875
994
  f"{self.__class__.__name__} `label` argument is specified as "
876
995
  f"{label_arg!r}, but a distinct label is implied by the sequence "
@@ -887,7 +1006,7 @@ class ValueSequence(JSONLike):
887
1006
  f"`resource` sequences."
888
1007
  )
889
1008
  try:
890
- self.app.ActionScope.from_json_like(path_split[1])
1009
+ self._app.ActionScope.from_json_like(path_split[1])
891
1010
  except Exception as err:
892
1011
  raise MalformedParameterPathError(
893
1012
  f"Cannot parse a resource action scope from the second component of the "
@@ -895,42 +1014,42 @@ class ValueSequence(JSONLike):
895
1014
  ) from None
896
1015
 
897
1016
  if len(path_split) > 2:
898
- path_split_2 = path_split[2]
899
- allowed = ResourceSpec.ALLOWED_PARAMETERS
900
- if path_split_2 not in allowed:
901
- allowed_keys_str = ", ".join(f'"{i}"' for i in allowed)
1017
+ if path_split[2] not in ResourceSpec.ALLOWED_PARAMETERS:
902
1018
  raise UnknownResourceSpecItemError(
903
- f"Resource item name {path_split_2!r} is unknown. Allowed "
904
- f"resource item names are: {allowed_keys_str}."
1019
+ f"Resource item name {path_split[2]!r} is unknown. Allowed "
1020
+ f"resource item names are: {ResourceSpec._allowed_params_quoted()}."
905
1021
  )
1022
+ label = ""
906
1023
 
907
1024
  elif path_split[0] == "environments":
908
1025
  # rewrite as a resources path:
909
1026
  path = f"resources.any.{path}"
910
-
911
- # note: `env_preset` paths also need to be transformed into `resources` paths, but
912
- # we cannot do that until the sequence is part of a task, since the available
913
- # environment presets are defined in the task schema.
1027
+ label = str(label) if label is not None else ""
1028
+ else:
1029
+ pass
1030
+ # note: `env_preset` paths also need to be transformed into `resources`
1031
+ # paths, but we cannot do that until the sequence is part of a task, since
1032
+ # the available environment presets are defined in the task schema.
914
1033
 
915
1034
  return path, label
916
1035
 
917
- def to_dict(self):
918
- out = super().to_dict()
1036
+ @override
1037
+ def _postprocess_to_dict(self, d: dict[str, Any]) -> dict[str, Any]:
1038
+ out = super()._postprocess_to_dict(d)
919
1039
  del out["_parameter"]
920
1040
  del out["_path_split"]
921
- if "_workflow" in out:
922
- del out["_workflow"]
1041
+ out.pop("_workflow", None)
923
1042
  return out
924
1043
 
925
1044
  @property
926
- def normalised_path(self):
1045
+ def normalised_path(self) -> str:
927
1046
  """
928
1047
  The path to this sequence.
929
1048
  """
930
1049
  return self.path
931
1050
 
932
1051
  @property
933
- def normalised_inputs_path(self):
1052
+ def normalised_inputs_path(self) -> str | None:
934
1053
  """
935
1054
  The normalised path without the "inputs" prefix, if the sequence is an
936
1055
  inputs sequence, else return None.
@@ -941,63 +1060,68 @@ class ValueSequence(JSONLike):
941
1060
  return f"{self.labelled_type}.{self.input_path}"
942
1061
  else:
943
1062
  return self.labelled_type
1063
+ return None
944
1064
 
945
1065
  def make_persistent(
946
- self, workflow: app.Workflow, source: Dict
947
- ) -> Tuple[str, List[int], bool]:
1066
+ self, workflow: Workflow, source: ParamSource
1067
+ ) -> tuple[str, list[int], bool]:
948
1068
  """Save value to a persistent workflow."""
949
1069
 
950
1070
  if self._values_group_idx is not None:
951
- is_new = False
952
- data_ref = self._values_group_idx
953
- if not all(workflow.check_parameters_exist(data_ref)):
1071
+ if not workflow.check_parameters_exist(self._values_group_idx):
954
1072
  raise RuntimeError(
955
1073
  f"{self.__class__.__name__} has a parameter group index "
956
- f"({data_ref}), but does not exist in the workflow."
1074
+ f"({self._values_group_idx}), but does not exist in the workflow."
957
1075
  )
958
1076
  # TODO: log if already persistent.
1077
+ return self.normalised_path, self._values_group_idx, False
959
1078
 
960
- else:
961
- data_ref = []
962
- source = copy.deepcopy(source)
1079
+ data_ref: list[int] = []
1080
+ source = copy.deepcopy(source)
1081
+ if self.value_class_method:
963
1082
  source["value_class_method"] = self.value_class_method
964
- are_objs = []
965
- for idx, i in enumerate(self._values):
966
- # record if ParameterValue sub-classes are passed for values, which allows
967
- # us to re-init the objects on access to `.value`:
968
- are_objs.append(isinstance(i, ParameterValue))
969
- source = copy.deepcopy(source)
970
- source["sequence_idx"] = idx
971
- pg_idx_i = workflow._add_parameter_data(i, source=source)
972
- data_ref.append(pg_idx_i)
973
-
974
- is_new = True
975
- self._values_group_idx = data_ref
976
- self._workflow = workflow
977
- self._values = None
978
- self._values_are_objs = are_objs
1083
+ are_objs: list[bool] = []
1084
+ assert self._values is not None
1085
+ for idx, item in enumerate(self._values):
1086
+ # record if ParameterValue sub-classes are passed for values, which allows
1087
+ # us to re-init the objects on access to `.value`:
1088
+ are_objs.append(isinstance(item, ParameterValue))
1089
+ source = copy.deepcopy(source)
1090
+ source["sequence_idx"] = idx
1091
+ pg_idx_i = workflow._add_parameter_data(item, source=source)
1092
+ data_ref.append(pg_idx_i)
979
1093
 
980
- return (self.normalised_path, data_ref, is_new)
1094
+ self._values_group_idx = data_ref
1095
+ self._workflow = workflow
1096
+ self._values = None
1097
+ self._values_are_objs = are_objs
1098
+ return self.normalised_path, data_ref, True
981
1099
 
982
1100
  @property
983
- def workflow(self):
1101
+ def workflow(self) -> Workflow | None:
984
1102
  """
985
1103
  The workflow containing this sequence.
986
1104
  """
987
1105
  if self._workflow:
1106
+ # (assigned in `make_persistent`)
988
1107
  return self._workflow
989
1108
  elif self._element_set:
990
- return self._element_set.task_template.workflow_template.workflow
1109
+ # (assigned by parent `ElementSet`)
1110
+ if tmpl := self._element_set.task_template.workflow_template:
1111
+ return tmpl.workflow
1112
+ return None
991
1113
 
992
1114
  @property
993
- def values(self):
1115
+ def values(self) -> Sequence[Any] | None:
994
1116
  """
995
1117
  The values in this sequence.
996
1118
  """
997
1119
  if self._values_group_idx is not None:
998
- vals = []
1120
+ vals: list[Any] = []
999
1121
  for idx, pg_idx_i in enumerate(self._values_group_idx):
1000
- param_i = self.workflow.get_parameter(pg_idx_i)
1122
+ if not (w := self.workflow):
1123
+ continue
1124
+ param_i = w.get_parameter(pg_idx_i)
1001
1125
  if param_i.data is not None:
1002
1126
  val_i = param_i.data
1003
1127
  else:
@@ -1007,16 +1131,11 @@ class ValueSequence(JSONLike):
1007
1131
  # yet been committed to disk:
1008
1132
  if (
1009
1133
  self.parameter
1010
- and self.parameter._value_class
1134
+ and self._values_are_objs
1011
1135
  and self._values_are_objs[idx]
1012
- and not isinstance(val_i, self.parameter._value_class)
1136
+ and isinstance(val_i, dict)
1013
1137
  ):
1014
- method_name = param_i.source.get("value_class_method")
1015
- if method_name:
1016
- method = getattr(self.parameter._value_class, method_name)
1017
- else:
1018
- method = self.parameter._value_class
1019
- val_i = method(**val_i)
1138
+ val_i = self.parameter._instantiate_value(param_i.source, val_i)
1020
1139
 
1021
1140
  vals.append(val_i)
1022
1141
  return vals
@@ -1024,52 +1143,72 @@ class ValueSequence(JSONLike):
1024
1143
  return self._values
1025
1144
 
1026
1145
  @classmethod
1027
- def _values_from_linear_space(cls, start, stop, num, **kwargs):
1146
+ def _values_from_linear_space(
1147
+ cls, start: float, stop: float, num: int, **kwargs
1148
+ ) -> list[float]:
1028
1149
  return np.linspace(start, stop, num=num, **kwargs).tolist()
1029
1150
 
1030
1151
  @classmethod
1031
- def _values_from_geometric_space(cls, start, stop, num, **kwargs):
1152
+ def _values_from_geometric_space(
1153
+ cls, start: float, stop: float, num: int, **kwargs
1154
+ ) -> list[float]:
1032
1155
  return np.geomspace(start, stop, num=num, **kwargs).tolist()
1033
1156
 
1034
1157
  @classmethod
1035
- def _values_from_log_space(cls, start, stop, num, base=10.0, **kwargs):
1158
+ def _values_from_log_space(
1159
+ cls, start: float, stop: float, num: int, base: float = 10.0, **kwargs
1160
+ ) -> list[float]:
1036
1161
  return np.logspace(start, stop, num=num, base=base, **kwargs).tolist()
1037
1162
 
1038
1163
  @classmethod
1039
- def _values_from_range(cls, start, stop, step, **kwargs):
1164
+ def _values_from_range(
1165
+ cls, start: int | float, stop: int | float, step: int | float, **kwargs
1166
+ ) -> list[float]:
1040
1167
  return np.arange(start, stop, step, **kwargs).tolist()
1041
1168
 
1042
1169
  @classmethod
1043
- def _values_from_file(cls, file_path):
1170
+ def _values_from_file(cls, file_path: str | Path) -> list[str]:
1044
1171
  with Path(file_path).open("rt") as fh:
1045
- vals = [i.strip() for i in fh.readlines()]
1046
- return vals
1172
+ return [line.strip() for line in fh.readlines()]
1047
1173
 
1048
1174
  @classmethod
1049
- def _values_from_rectangle(cls, start, stop, num, coord=None, include=None, **kwargs):
1175
+ def _values_from_rectangle(
1176
+ cls,
1177
+ start: Sequence[float],
1178
+ stop: Sequence[float],
1179
+ num: Sequence[int],
1180
+ coord: int | tuple[int, int] | None = None,
1181
+ include: Sequence[str] | None = None,
1182
+ **kwargs,
1183
+ ) -> list[float]:
1050
1184
  vals = linspace_rect(start=start, stop=stop, num=num, include=include, **kwargs)
1051
1185
  if coord is not None:
1052
- vals = vals[coord].tolist()
1186
+ return vals[coord].tolist()
1053
1187
  else:
1054
- vals = (vals.T).tolist()
1055
- return vals
1188
+ return (vals.T).tolist()
1056
1189
 
1057
1190
  @classmethod
1058
- def _values_from_random_uniform(cls, num, low=0.0, high=1.0, seed=None):
1191
+ def _values_from_random_uniform(
1192
+ cls,
1193
+ num: int,
1194
+ low: float = 0.0,
1195
+ high: float = 1.0,
1196
+ seed: int | list[int] | None = None,
1197
+ ) -> list[float]:
1059
1198
  rng = np.random.default_rng(seed)
1060
1199
  return rng.uniform(low=low, high=high, size=num).tolist()
1061
1200
 
1062
1201
  @classmethod
1063
1202
  def from_linear_space(
1064
1203
  cls,
1065
- path,
1066
- start,
1067
- stop,
1068
- num,
1069
- nesting_order=0,
1070
- label=None,
1204
+ path: str,
1205
+ start: float,
1206
+ stop: float,
1207
+ num: int,
1208
+ nesting_order: float = 0,
1209
+ label: str | int | None = None,
1071
1210
  **kwargs,
1072
- ):
1211
+ ) -> Self:
1073
1212
  """
1074
1213
  Build a sequence from a NumPy linear space.
1075
1214
  """
@@ -1084,15 +1223,15 @@ class ValueSequence(JSONLike):
1084
1223
  @classmethod
1085
1224
  def from_geometric_space(
1086
1225
  cls,
1087
- path,
1088
- start,
1089
- stop,
1090
- num,
1091
- nesting_order=0,
1226
+ path: str,
1227
+ start: float,
1228
+ stop: float,
1229
+ num: int,
1230
+ nesting_order: float = 0,
1092
1231
  endpoint=True,
1093
- label=None,
1232
+ label: str | int | None = None,
1094
1233
  **kwargs,
1095
- ):
1234
+ ) -> Self:
1096
1235
  """
1097
1236
  Build a sequence from a NumPy geometric space.
1098
1237
  """
@@ -1106,16 +1245,16 @@ class ValueSequence(JSONLike):
1106
1245
  @classmethod
1107
1246
  def from_log_space(
1108
1247
  cls,
1109
- path,
1110
- start,
1111
- stop,
1112
- num,
1113
- nesting_order=0,
1248
+ path: str,
1249
+ start: float,
1250
+ stop: float,
1251
+ num: int,
1252
+ nesting_order: float = 0,
1114
1253
  base=10.0,
1115
1254
  endpoint=True,
1116
- label=None,
1255
+ label: str | int | None = None,
1117
1256
  **kwargs,
1118
- ):
1257
+ ) -> Self:
1119
1258
  """
1120
1259
  Build a sequence from a NumPy logarithmic space.
1121
1260
  """
@@ -1136,14 +1275,14 @@ class ValueSequence(JSONLike):
1136
1275
  @classmethod
1137
1276
  def from_range(
1138
1277
  cls,
1139
- path,
1140
- start,
1141
- stop,
1142
- nesting_order=0,
1143
- step=1,
1144
- label=None,
1278
+ path: str,
1279
+ start: float,
1280
+ stop: float,
1281
+ nesting_order: float = 0,
1282
+ step: int | float = 1,
1283
+ label: str | int | None = None,
1145
1284
  **kwargs,
1146
- ):
1285
+ ) -> Self:
1147
1286
  """
1148
1287
  Build a sequence from a range.
1149
1288
  """
@@ -1173,12 +1312,12 @@ class ValueSequence(JSONLike):
1173
1312
  @classmethod
1174
1313
  def from_file(
1175
1314
  cls,
1176
- path,
1177
- file_path,
1178
- nesting_order=0,
1179
- label=None,
1315
+ path: str,
1316
+ file_path: str | Path,
1317
+ nesting_order: float = 0,
1318
+ label: str | int | None = None,
1180
1319
  **kwargs,
1181
- ):
1320
+ ) -> Self:
1182
1321
  """
1183
1322
  Build a sequence from a simple file.
1184
1323
  """
@@ -1198,16 +1337,16 @@ class ValueSequence(JSONLike):
1198
1337
  @classmethod
1199
1338
  def from_rectangle(
1200
1339
  cls,
1201
- path,
1202
- start,
1203
- stop,
1204
- num,
1205
- coord: Optional[int] = None,
1206
- include: Optional[list[str]] = None,
1207
- nesting_order=0,
1208
- label=None,
1340
+ path: str,
1341
+ start: Sequence[float],
1342
+ stop: Sequence[float],
1343
+ num: Sequence[int],
1344
+ coord: int | None = None,
1345
+ include: list[str] | None = None,
1346
+ nesting_order: float = 0,
1347
+ label: str | int | None = None,
1209
1348
  **kwargs,
1210
- ):
1349
+ ) -> Self:
1211
1350
  """
1212
1351
  Build a sequence to cover a rectangle.
1213
1352
 
@@ -1238,14 +1377,14 @@ class ValueSequence(JSONLike):
1238
1377
  def from_random_uniform(
1239
1378
  cls,
1240
1379
  path,
1241
- num,
1242
- low=0.0,
1243
- high=1.0,
1244
- seed=None,
1245
- nesting_order=0,
1246
- label=None,
1380
+ num: int,
1381
+ low: float = 0.0,
1382
+ high: float = 1.0,
1383
+ seed: int | list[int] | None = None,
1384
+ nesting_order: float = 0,
1385
+ label: str | int | None = None,
1247
1386
  **kwargs,
1248
- ):
1387
+ ) -> Self:
1249
1388
  """
1250
1389
  Build a sequence from a uniform random number generator.
1251
1390
  """
@@ -1257,13 +1396,264 @@ class ValueSequence(JSONLike):
1257
1396
  return obj
1258
1397
 
1259
1398
 
1399
+ class MultiPathSequence(_BaseSequence):
1400
+ """
1401
+ A sequence of values to be distributed across one or more paths.
1402
+
1403
+ Notes
1404
+ -----
1405
+ This is useful when we would like to generate values for multiple input paths that
1406
+ have some interdependency, or when they must be generate together in one go.
1407
+
1408
+ Parameters
1409
+ ----------
1410
+ paths:
1411
+ The paths to this multi-path sequence.
1412
+ values:
1413
+ The values in this multi-path sequence.
1414
+ nesting_order: int
1415
+ A nesting order for this multi-path sequence. Can be used to compose sequences
1416
+ together.
1417
+ label: str
1418
+ A label for this multi-path sequence.
1419
+ value_class_method: str
1420
+ Name of a method used to generate multi-path sequence values. Not normally used
1421
+ directly.
1422
+ """
1423
+
1424
+ # TODO: add a `path_axis` argument with doc string like:
1425
+ # path_axis:
1426
+ # The axis (as in a Numpy axis) along `values` to which the different paths
1427
+ # correspond.
1428
+
1429
+ def __init__(
1430
+ self,
1431
+ paths: Sequence[str],
1432
+ values: NDArray | Sequence[Sequence] | None,
1433
+ nesting_order: int | float | None = None,
1434
+ label: str | int | None = None,
1435
+ value_class_method: str | None = None,
1436
+ ):
1437
+ self.paths = list(paths)
1438
+ self.nesting_order = nesting_order
1439
+ self.label = label
1440
+ self.value_class_method = value_class_method
1441
+
1442
+ self._sequences: list[ValueSequence] | None = None
1443
+ self._values: NDArray | Sequence[Sequence] | None = None
1444
+
1445
+ if values is not None:
1446
+ if (len_paths := len(paths)) != (len_vals := len(values)):
1447
+ raise ValueError(
1448
+ f"The number of values ({len_vals}) must be equal to the number of "
1449
+ f"paths provided ({len_paths})."
1450
+ )
1451
+ self._values = values
1452
+ self._sequences = [
1453
+ self._app.ValueSequence(
1454
+ path=path,
1455
+ values=values[idx],
1456
+ label=label,
1457
+ nesting_order=nesting_order,
1458
+ value_class_method=value_class_method,
1459
+ )
1460
+ for idx, path in enumerate(paths)
1461
+ ]
1462
+
1463
+ # assigned by `_move_to_sequence_list` (invoked by first init of parent
1464
+ # `ElementSet`), corresponds to the sequence indices with the element set's
1465
+ # sequence list:
1466
+ self._sequence_indices: Sequence[int] | None = None
1467
+
1468
+ self._element_set: ElementSet | None = None # assigned by parent `ElementSet`
1469
+
1470
+ self._values_method: str | None = None
1471
+ self._values_method_args: dict | None = None
1472
+
1473
+ def __repr__(self):
1474
+
1475
+ label_str = f"label={self.label!r}, " if self.label else ""
1476
+ val_cls_str = (
1477
+ f"value_class_method={self.value_class_method!r}, "
1478
+ if self.value_class_method
1479
+ else ""
1480
+ )
1481
+ return (
1482
+ f"{self.__class__.__name__}("
1483
+ f"paths={self.paths!r}, "
1484
+ f"{label_str}"
1485
+ f"nesting_order={self.nesting_order}, "
1486
+ f"{val_cls_str}"
1487
+ f"values={self.values}"
1488
+ f")"
1489
+ )
1490
+
1491
+ def __deepcopy__(self, memo: dict[int, Any]):
1492
+ kwargs = self.to_dict()
1493
+ kwargs["values"] = kwargs.pop("_values")
1494
+
1495
+ _sequences = kwargs.pop("_sequences", None)
1496
+ _sequence_indices = kwargs.pop("_sequence_indices", None)
1497
+ _values_method = kwargs.pop("_values_method", None)
1498
+ _values_method_args = kwargs.pop("_values_method_args", None)
1499
+
1500
+ obj = self.__class__(**copy.deepcopy(kwargs, memo))
1501
+
1502
+ obj._sequences = _sequences
1503
+ obj._sequence_indices = _sequence_indices
1504
+ obj._values_method = _values_method
1505
+ obj._values_method_args = _values_method_args
1506
+
1507
+ obj._element_set = self._element_set
1508
+
1509
+ return obj
1510
+
1511
+ @override
1512
+ def _postprocess_to_dict(self, d: dict[str, Any]) -> dict[str, Any]:
1513
+ dct = super()._postprocess_to_dict(d)
1514
+ del dct["_sequences"]
1515
+ return dct
1516
+
1517
+ @classmethod
1518
+ def _json_like_constructor(cls, json_like):
1519
+ """Invoked by `JSONLike.from_json_like` instead of `__init__`."""
1520
+
1521
+ # pop the keys we don't accept in `__init__`, and then assign after `__init__`:
1522
+ _sequence_indices = json_like.pop("_sequence_indices", None)
1523
+
1524
+ _values_method = json_like.pop("_values_method", None)
1525
+ _values_method_args = json_like.pop("_values_method_args", None)
1526
+ if "_values" in json_like:
1527
+ json_like["values"] = json_like.pop("_values")
1528
+
1529
+ obj = cls(**json_like)
1530
+ obj._sequence_indices = _sequence_indices
1531
+ obj._values_method = _values_method
1532
+ obj._values_method_args = _values_method_args
1533
+ return obj
1534
+
1535
+ @property
1536
+ def sequence_indices(self) -> Sequence[int] | None:
1537
+ """
1538
+ The range indices (start and stop) to the parent element set's sequences list that
1539
+ correspond to the `ValueSequence`s generated by this multi-path sequence, if this
1540
+ object is bound to a parent element set.
1541
+ """
1542
+ return self._sequence_indices
1543
+
1544
+ @property
1545
+ def sequences(self) -> Sequence[ValueSequence]:
1546
+ """
1547
+ The child value sequences, one for each path.
1548
+ """
1549
+ if self._sequence_indices:
1550
+ # they are stored in the parent `ElementSet`
1551
+ assert self._element_set
1552
+ return self._element_set.sequences[slice(*self._sequence_indices)]
1553
+ else:
1554
+ # not yet bound to a parent `ElementSet`
1555
+ assert self._sequences
1556
+ return self._sequences
1557
+
1558
+ @property
1559
+ def values(self) -> list[Sequence[Any]]:
1560
+ values = []
1561
+ for seq_i in self.sequences:
1562
+ assert seq_i.values
1563
+ values.append(seq_i.values)
1564
+ return values
1565
+
1566
+ def _move_to_sequence_list(self, sequences: list[ValueSequence]) -> None:
1567
+ """
1568
+ Move the individual value sequences to an external list of value sequences (i.e.,
1569
+ the parent `ElementSet`'s), and update the `sequence_indices` attribute so we can
1570
+ retrieve the sequences from that list at will.
1571
+ """
1572
+ len_ours = len(self.sequences)
1573
+ len_ext = len(sequences)
1574
+ sequences.extend(self.sequences)
1575
+
1576
+ # child sequences are now stored externally, and values retrieved via those:
1577
+ self._sequences = None
1578
+ self._values = None
1579
+ self._sequence_indices = [len_ext, len_ext + len_ours]
1580
+
1581
+ @classmethod
1582
+ def _values_from_latin_hypercube(
1583
+ cls,
1584
+ paths: Sequence[str],
1585
+ num_samples: int,
1586
+ *,
1587
+ scramble: bool = True,
1588
+ strength: int = 1,
1589
+ optimization: Literal["random-cd", "lloyd"] | None = None,
1590
+ rng=None,
1591
+ ) -> NDArray:
1592
+
1593
+ num_paths = len(paths)
1594
+ kwargs = dict(
1595
+ d=num_paths,
1596
+ scramble=scramble,
1597
+ strength=strength,
1598
+ optimization=optimization,
1599
+ rng=rng,
1600
+ )
1601
+ try:
1602
+ sampler = LatinHypercube(**kwargs)
1603
+ except TypeError:
1604
+ # `rng` was previously (<1.15.0) `seed`:
1605
+ kwargs["seed"] = kwargs.pop("rng")
1606
+ sampler = LatinHypercube(**kwargs)
1607
+ return sampler.random(n=num_samples).T
1608
+
1609
+ @classmethod
1610
+ def from_latin_hypercube(
1611
+ cls,
1612
+ paths: Sequence[str],
1613
+ num_samples: int,
1614
+ *,
1615
+ scramble: bool = True,
1616
+ strength: int = 1,
1617
+ optimization: Literal["random-cd", "lloyd"] | None = None,
1618
+ rng=None,
1619
+ nesting_order: int | float | None = None,
1620
+ label: str | int | None = None,
1621
+ ) -> Self:
1622
+ """
1623
+ Generate values from SciPy's latin hypercube sampler: :class:`scipy.stats.qmc.LatinHypercube`.
1624
+ """
1625
+ kwargs = {
1626
+ "paths": paths,
1627
+ "num_samples": num_samples,
1628
+ "scramble": scramble,
1629
+ "strength": strength,
1630
+ "optimization": optimization,
1631
+ "rng": rng,
1632
+ }
1633
+ values = cls._values_from_latin_hypercube(**kwargs)
1634
+ assert values is not None
1635
+ obj = cls(
1636
+ paths=paths,
1637
+ values=values,
1638
+ nesting_order=nesting_order,
1639
+ label=label,
1640
+ )
1641
+ obj._values_method = "from_latin_hypercube"
1642
+ obj._values_method_args = kwargs
1643
+ return obj
1644
+
1645
+
1260
1646
  @dataclass
1261
1647
  class AbstractInputValue(JSONLike):
1262
1648
  """Class to represent all sequence-able inputs to a task."""
1263
1649
 
1264
- _workflow = None
1650
+ _workflow: Workflow | None = None
1651
+ _element_set: ElementSet | None = None
1652
+ _schema_input: SchemaInput | None = None
1653
+ _value: Any | None = None
1654
+ _value_group_idx: int | list[int] | None = None
1265
1655
 
1266
- def __repr__(self):
1656
+ def __repr__(self) -> str:
1267
1657
  try:
1268
1658
  value_str = f", value={self.value}"
1269
1659
  except WorkflowParameterMissingError:
@@ -1276,25 +1666,26 @@ class AbstractInputValue(JSONLike):
1276
1666
  f")"
1277
1667
  )
1278
1668
 
1279
- def to_dict(self):
1280
- out = super().to_dict()
1281
- if "_workflow" in out:
1282
- del out["_workflow"]
1283
- if "_schema_input" in out:
1284
- del out["_schema_input"]
1669
+ @override
1670
+ def _postprocess_to_dict(self, d: dict[str, Any]) -> dict[str, Any]:
1671
+ out = super()._postprocess_to_dict(d)
1672
+ out.pop("_workflow", None)
1673
+ out.pop("_schema_input", None)
1285
1674
  return out
1286
1675
 
1287
1676
  def make_persistent(
1288
- self, workflow: app.Workflow, source: Dict
1289
- ) -> Tuple[str, List[int], bool]:
1677
+ self, workflow: Workflow, source: ParamSource
1678
+ ) -> tuple[str, list[int | list[int]], bool]:
1290
1679
  """Save value to a persistent workflow.
1291
1680
 
1292
1681
  Returns
1293
1682
  -------
1294
- String is the data path for this task input and single item integer list
1295
- contains the index of the parameter data Zarr group where the data is
1296
- stored.
1297
-
1683
+ str
1684
+ Normalised path for this task input.
1685
+ list[int | list[int]]
1686
+ The index of the parameter data Zarr group where the data is stored.
1687
+ bool
1688
+ Whether this is newly persistent.
1298
1689
  """
1299
1690
 
1300
1691
  if self._value_group_idx is not None:
@@ -1315,30 +1706,34 @@ class AbstractInputValue(JSONLike):
1315
1706
  return (self.normalised_path, [data_ref], is_new)
1316
1707
 
1317
1708
  @property
1318
- def workflow(self):
1709
+ def normalised_path(self) -> str:
1710
+ """
1711
+ The normalised path, if known.
1712
+ """
1713
+ raise NotImplementedError
1714
+
1715
+ @property
1716
+ def workflow(self) -> Workflow | None:
1319
1717
  """
1320
1718
  The workflow containing this input value.
1321
1719
  """
1322
1720
  if self._workflow:
1323
1721
  return self._workflow
1324
- elif self._element_set:
1325
- return self._element_set.task_template.workflow_template.workflow
1326
- elif self._schema_input:
1327
- return self._schema_input.task_schema.task_template.workflow_template.workflow
1722
+ if self._element_set:
1723
+ if w_tmpl := self._element_set.task_template.workflow_template:
1724
+ return w_tmpl.workflow
1725
+ if self._schema_input:
1726
+ if t_tmpl := self._schema_input.task_schema.task_template:
1727
+ if w_tmpl := t_tmpl.workflow_template:
1728
+ return w_tmpl.workflow
1729
+ return None
1328
1730
 
1329
1731
  @property
1330
- def value(self):
1732
+ def value(self) -> Any:
1331
1733
  """
1332
1734
  The value itself.
1333
1735
  """
1334
- if self._value_group_idx is not None:
1335
- val = self.workflow.get_parameter_data(self._value_group_idx)
1336
- if self._value_is_obj and self.parameter._value_class:
1337
- val = self.parameter._value_class(**val)
1338
- else:
1339
- val = self._value
1340
-
1341
- return val
1736
+ return self._value
1342
1737
 
1343
1738
 
1344
1739
  @dataclass
@@ -1348,13 +1743,16 @@ class ValuePerturbation(AbstractInputValue):
1348
1743
  """
1349
1744
 
1350
1745
  #: The name of this perturbation.
1351
- name: str
1746
+ name: str = ""
1352
1747
  #: The path to the value(s) to perturb.
1353
- path: Optional[Sequence[Union[str, int, float]]] = None
1748
+ path: Sequence[str | int | float] | None = None
1354
1749
  #: The multiplicative factor to apply.
1355
- multiplicative_factor: Optional[Numeric] = 1
1750
+ multiplicative_factor: Numeric | None = 1
1356
1751
  #: The additive factor to apply.
1357
- additive_factor: Optional[Numeric] = 0
1752
+ additive_factor: Numeric | None = 0
1753
+
1754
+ def __post_init__(self):
1755
+ assert self.name
1358
1756
 
1359
1757
  @classmethod
1360
1758
  def from_spec(cls, spec):
@@ -1364,6 +1762,7 @@ class ValuePerturbation(AbstractInputValue):
1364
1762
  return cls(**spec)
1365
1763
 
1366
1764
 
1765
+ @hydrate
1367
1766
  class InputValue(AbstractInputValue):
1368
1767
  """
1369
1768
  An input value to a task.
@@ -1386,7 +1785,7 @@ class InputValue(AbstractInputValue):
1386
1785
 
1387
1786
  """
1388
1787
 
1389
- _child_objects = (
1788
+ _child_objects: ClassVar[tuple[ChildObjectSpec, ...]] = (
1390
1789
  ChildObjectSpec(
1391
1790
  name="parameter",
1392
1791
  class_name="Parameter",
@@ -1397,45 +1796,40 @@ class InputValue(AbstractInputValue):
1397
1796
 
1398
1797
  def __init__(
1399
1798
  self,
1400
- parameter: Union[app.Parameter, str],
1401
- value: Optional[Any] = None,
1402
- label: Optional[str] = None,
1403
- value_class_method: Optional[str] = None,
1404
- path: Optional[str] = None,
1405
- __check_obj: Optional[bool] = True,
1799
+ parameter: Parameter | SchemaInput | str,
1800
+ value: Any | None = None,
1801
+ label: str | int | None = None,
1802
+ value_class_method: str | None = None,
1803
+ path: str | None = None,
1804
+ _check_obj: bool = True,
1406
1805
  ):
1806
+ super().__init__()
1407
1807
  if isinstance(parameter, str):
1408
1808
  try:
1409
- parameter = self.app.parameters.get(parameter)
1809
+ #: Parameter whose value is to be specified.
1810
+ self.parameter = self._app.parameters.get(parameter)
1410
1811
  except ValueError:
1411
- parameter = self.app.Parameter(parameter)
1812
+ self.parameter = self._app.Parameter(parameter)
1412
1813
  elif isinstance(parameter, SchemaInput):
1413
- parameter = parameter.parameter
1814
+ self.parameter = parameter.parameter
1815
+ else:
1816
+ self.parameter = parameter
1414
1817
 
1415
- #: Parameter whose value is to be specified.
1416
- self.parameter = parameter
1417
1818
  #: Identifier to be used where the associated `SchemaInput` accepts multiple
1418
1819
  #: parameters of the specified type.
1419
1820
  self.label = str(label) if label is not None else ""
1420
1821
  #: Dot-delimited path within the parameter's nested data structure for which
1421
1822
  #: `value` should be set.
1422
- self.path = (path.strip(".") if path else None) or None
1823
+ self.path = (path.strip(".") or None) if path else None
1423
1824
  #: A class method that can be invoked with the `value` attribute as keyword
1424
1825
  #: arguments.
1425
1826
  self.value_class_method = value_class_method
1426
- self._value = _process_demo_data_strings(self.app, value)
1427
-
1428
- self._value_group_idx = None # assigned by method make_persistent
1429
- self._element_set = None # assigned by parent ElementSet (if belonging)
1430
-
1431
- # assigned by parent SchemaInput (if this object is a default value of a
1432
- # SchemaInput):
1433
- self._schema_input = None
1827
+ self._value = _process_demo_data_strings(self._app, value)
1434
1828
 
1435
1829
  # record if a ParameterValue sub-class is passed for value, which allows us
1436
1830
  # to re-init the object on `.value`:
1437
1831
  self._value_is_obj = isinstance(value, ParameterValue)
1438
- if __check_obj:
1832
+ if _check_obj:
1439
1833
  self._check_dict_value_if_object()
1440
1834
 
1441
1835
  def _check_dict_value_if_object(self):
@@ -1459,13 +1853,13 @@ class InputValue(AbstractInputValue):
1459
1853
  f"dict."
1460
1854
  )
1461
1855
 
1462
- def __deepcopy__(self, memo):
1856
+ def __deepcopy__(self, memo: dict[int, Any]) -> Self:
1463
1857
  kwargs = self.to_dict()
1464
1858
  _value = kwargs.pop("_value")
1465
1859
  kwargs.pop("_schema_input", None)
1466
1860
  _value_group_idx = kwargs.pop("_value_group_idx")
1467
1861
  _value_is_obj = kwargs.pop("_value_is_obj")
1468
- obj = self.__class__(**copy.deepcopy(kwargs, memo), _InputValue__check_obj=False)
1862
+ obj = self.__class__(**copy.deepcopy(kwargs, memo), _check_obj=False)
1469
1863
  obj._value = _value
1470
1864
  obj._value_group_idx = _value_group_idx
1471
1865
  obj._value_is_obj = _value_is_obj
@@ -1473,7 +1867,7 @@ class InputValue(AbstractInputValue):
1473
1867
  obj._schema_input = self._schema_input
1474
1868
  return obj
1475
1869
 
1476
- def __repr__(self):
1870
+ def __repr__(self) -> str:
1477
1871
  val_grp_idx = ""
1478
1872
  if self._value_group_idx is not None:
1479
1873
  val_grp_idx = f", value_group_idx={self._value_group_idx}"
@@ -1500,12 +1894,10 @@ class InputValue(AbstractInputValue):
1500
1894
  f")"
1501
1895
  )
1502
1896
 
1503
- def __eq__(self, other) -> bool:
1897
+ def __eq__(self, other: Any) -> bool:
1504
1898
  if not isinstance(other, self.__class__):
1505
1899
  return False
1506
- if self.to_dict() == other.to_dict():
1507
- return True
1508
- return False
1900
+ return self.to_dict() == other.to_dict()
1509
1901
 
1510
1902
  @classmethod
1511
1903
  def _json_like_constructor(cls, json_like):
@@ -1516,14 +1908,14 @@ class InputValue(AbstractInputValue):
1516
1908
  if "_value" in json_like:
1517
1909
  json_like["value"] = json_like.pop("_value")
1518
1910
 
1519
- obj = cls(**json_like, _InputValue__check_obj=False)
1911
+ obj = cls(**json_like, _check_obj=False)
1520
1912
  obj._value_group_idx = _value_group_idx
1521
1913
  obj._value_is_obj = _value_is_obj
1522
1914
  obj._check_dict_value_if_object()
1523
1915
  return obj
1524
1916
 
1525
1917
  @property
1526
- def labelled_type(self):
1918
+ def labelled_type(self) -> str:
1527
1919
  """
1528
1920
  The labelled type of this input value.
1529
1921
  """
@@ -1531,22 +1923,25 @@ class InputValue(AbstractInputValue):
1531
1923
  return f"{self.parameter.typ}{label}"
1532
1924
 
1533
1925
  @property
1534
- def normalised_inputs_path(self):
1926
+ def normalised_inputs_path(self) -> str:
1535
1927
  """
1536
1928
  The normalised input path without the ``inputs.`` prefix.
1537
1929
  """
1538
1930
  return f"{self.labelled_type}{f'.{self.path}' if self.path else ''}"
1539
1931
 
1540
1932
  @property
1541
- def normalised_path(self):
1933
+ def normalised_path(self) -> str:
1542
1934
  """
1543
1935
  The full normalised input path.
1544
1936
  """
1545
1937
  return f"inputs.{self.normalised_inputs_path}"
1546
1938
 
1547
- def make_persistent(self, workflow: Any, source: Dict) -> Tuple[str, List[int], bool]:
1939
+ def make_persistent(
1940
+ self, workflow: Workflow, source: ParamSource
1941
+ ) -> tuple[str, list[int | list[int]], bool]:
1548
1942
  source = copy.deepcopy(source)
1549
- source["value_class_method"] = self.value_class_method
1943
+ if self.value_class_method is not None:
1944
+ source["value_class_method"] = self.value_class_method
1550
1945
  return super().make_persistent(workflow, source)
1551
1946
 
1552
1947
  @classmethod
@@ -1558,25 +1953,37 @@ class InputValue(AbstractInputValue):
1558
1953
  json_like["label"] = label
1559
1954
 
1560
1955
  if "::" in json_like["parameter"]:
1956
+ # double-colon syntax indicates a `ParameterValue`-subclass class method
1957
+ # of the specified name should be used to construct the values:
1561
1958
  param, cls_method = json_like["parameter"].split("::")
1562
1959
  json_like["parameter"] = param
1563
1960
  json_like["value_class_method"] = cls_method
1564
1961
 
1565
1962
  if "path" not in json_like:
1566
- param_spec = json_like["parameter"].split(".")
1567
- json_like["parameter"] = param_spec[0]
1568
- json_like["path"] = ".".join(param_spec[1:])
1569
-
1570
- obj = super().from_json_like(json_like, shared_data)
1963
+ # in the case this value corresponds to some sub-part of the parameter's
1964
+ # nested data structure:
1965
+ param, *path = json_like["parameter"].split(".")
1966
+ json_like["parameter"] = param
1967
+ json_like["path"] = ".".join(path)
1571
1968
 
1572
- return obj
1969
+ return super().from_json_like(json_like, shared_data)
1573
1970
 
1574
1971
  @property
1575
- def is_sub_value(self):
1972
+ def is_sub_value(self) -> bool:
1576
1973
  """True if the value is for a sub part of the parameter (i.e. if `path` is set).
1577
1974
  Sub-values are not added to the base parameter data, but are interpreted as
1578
1975
  single-value sequences."""
1579
- return True if self.path else False
1976
+ return bool(self.path)
1977
+
1978
+ @property
1979
+ def value(self) -> Any:
1980
+ if self._value_group_idx is not None and self.workflow:
1981
+ val = self.workflow.get_parameter_data(cast("int", self._value_group_idx))
1982
+ if self._value_is_obj and self.parameter._value_class:
1983
+ return self.parameter._value_class(**val)
1984
+ return val
1985
+ else:
1986
+ return self._value
1580
1987
 
1581
1988
 
1582
1989
  class ResourceSpec(JSONLike):
@@ -1611,6 +2018,12 @@ class ResourceSpec(JSONLike):
1611
2018
  Whether to use array jobs.
1612
2019
  max_array_items: int
1613
2020
  If using array jobs, up to how many items should be in the job array.
2021
+ write_app_logs: bool
2022
+ Whether an app log file should be written.
2023
+ combine_jobscript_std: bool
2024
+ Whether jobscript standard output and error streams should be combined.
2025
+ combine_scripts: bool
2026
+ Whether Python scripts should be combined.
1614
2027
  time_limit: str
1615
2028
  How long to run for.
1616
2029
  scheduler_args: dict[str, Any]
@@ -1621,6 +2034,13 @@ class ResourceSpec(JSONLike):
1621
2034
  Which OS to use.
1622
2035
  environments: dict
1623
2036
  Which execution environments to use.
2037
+ resources_id: int
2038
+ An arbitrary integer that can be used to force multiple jobscripts.
2039
+ skip_downstream_on_failure: bool
2040
+ Whether to skip downstream dependents on failure.
2041
+ allow_failed_dependencies: int | float | bool | None
2042
+ The failure tolerance with respect to dependencies, specified as a number or
2043
+ proportion.
1624
2044
  SGE_parallel_env: str
1625
2045
  Which SGE parallel environment to request.
1626
2046
  SLURM_partition: str
@@ -1636,7 +2056,7 @@ class ResourceSpec(JSONLike):
1636
2056
  """
1637
2057
 
1638
2058
  #: The names of parameters that may be used when making an instance of this class.
1639
- ALLOWED_PARAMETERS = {
2059
+ ALLOWED_PARAMETERS: ClassVar[set[str]] = {
1640
2060
  "scratch",
1641
2061
  "parallel_mode",
1642
2062
  "num_cores",
@@ -1647,11 +2067,16 @@ class ResourceSpec(JSONLike):
1647
2067
  "shell",
1648
2068
  "use_job_array",
1649
2069
  "max_array_items",
2070
+ "write_app_logs",
2071
+ "combine_jobscript_std",
2072
+ "combine_scripts",
1650
2073
  "time_limit",
1651
2074
  "scheduler_args",
1652
2075
  "shell_args",
1653
2076
  "os_name",
1654
2077
  "environments",
2078
+ "resources_id",
2079
+ "skip_downstream_on_failure",
1655
2080
  "SGE_parallel_env",
1656
2081
  "SLURM_partition",
1657
2082
  "SLURM_num_tasks",
@@ -1660,51 +2085,76 @@ class ResourceSpec(JSONLike):
1660
2085
  "SLURM_num_cpus_per_task",
1661
2086
  }
1662
2087
 
1663
- _resource_list = None
2088
+ _resource_list: ResourceList | None = None
1664
2089
 
1665
- _child_objects = (
2090
+ _child_objects: ClassVar[tuple[ChildObjectSpec, ...]] = (
1666
2091
  ChildObjectSpec(
1667
2092
  name="scope",
1668
2093
  class_name="ActionScope",
1669
2094
  ),
1670
2095
  )
1671
2096
 
2097
+ @staticmethod
2098
+ def __quoted(values: Iterable):
2099
+ return ", ".join(f'"{item}"' for item in values)
2100
+
2101
+ @classmethod
2102
+ def _allowed_params_quoted(cls) -> str:
2103
+ """
2104
+ The string version of the list of allowed parameters.
2105
+ """
2106
+ return cls.__quoted(cls.ALLOWED_PARAMETERS)
2107
+
2108
+ @staticmethod
2109
+ def __parse_thing(
2110
+ typ: type[ActionScope], val: ActionScope | str | None
2111
+ ) -> ActionScope | None:
2112
+ if isinstance(val, typ):
2113
+ return val
2114
+ elif val is None:
2115
+ return typ.any()
2116
+ else:
2117
+ return typ.from_json_like(cast("str", val))
2118
+
1672
2119
  def __init__(
1673
2120
  self,
1674
- scope: app.ActionScope = None,
1675
- scratch: Optional[str] = None,
1676
- parallel_mode: Optional[Union[str, ParallelMode]] = None,
1677
- num_cores: Optional[int] = None,
1678
- num_cores_per_node: Optional[int] = None,
1679
- num_threads: Optional[int] = None,
1680
- num_nodes: Optional[int] = None,
1681
- scheduler: Optional[str] = None,
1682
- shell: Optional[str] = None,
1683
- use_job_array: Optional[bool] = None,
1684
- max_array_items: Optional[int] = None,
1685
- time_limit: Optional[Union[str, timedelta]] = None,
1686
- scheduler_args: Optional[Dict] = None,
1687
- shell_args: Optional[Dict] = None,
1688
- os_name: Optional[str] = None,
1689
- environments: Optional[Dict] = None,
1690
- SGE_parallel_env: Optional[str] = None,
1691
- SLURM_partition: Optional[str] = None,
1692
- SLURM_num_tasks: Optional[str] = None,
1693
- SLURM_num_tasks_per_node: Optional[str] = None,
1694
- SLURM_num_nodes: Optional[str] = None,
1695
- SLURM_num_cpus_per_task: Optional[str] = None,
2121
+ scope: ActionScope | str | None = None,
2122
+ scratch: str | None = None,
2123
+ parallel_mode: str | ParallelMode | None = None,
2124
+ num_cores: int | None = None,
2125
+ num_cores_per_node: int | None = None,
2126
+ num_threads: int | None = None,
2127
+ num_nodes: int | None = None,
2128
+ scheduler: str | None = None,
2129
+ shell: str | None = None,
2130
+ use_job_array: bool | None = None,
2131
+ max_array_items: int | None = None,
2132
+ write_app_logs: bool | None = None,
2133
+ combine_jobscript_std: bool | None = None,
2134
+ combine_scripts: bool | None = None,
2135
+ time_limit: str | timedelta | None = None,
2136
+ scheduler_args: dict[str, Any] | None = None,
2137
+ shell_args: dict[str, Any] | None = None,
2138
+ os_name: str | None = None,
2139
+ environments: Mapping[str, Mapping[str, Any]] | None = None,
2140
+ resources_id: int | None = None,
2141
+ skip_downstream_on_failure: bool | None = None,
2142
+ SGE_parallel_env: str | None = None,
2143
+ SLURM_partition: str | None = None,
2144
+ SLURM_num_tasks: str | None = None,
2145
+ SLURM_num_tasks_per_node: str | None = None,
2146
+ SLURM_num_nodes: str | None = None,
2147
+ SLURM_num_cpus_per_task: str | None = None,
1696
2148
  ):
1697
2149
  #: Which scope does this apply to.
1698
- self.scope = scope or self.app.ActionScope.any()
1699
- if not isinstance(self.scope, self.app.ActionScope):
1700
- self.scope = self.app.ActionScope.from_json_like(self.scope)
2150
+ self.scope = self.__parse_thing(self._app.ActionScope, scope)
1701
2151
 
1702
2152
  if isinstance(time_limit, timedelta):
1703
2153
  time_limit = timedelta_format(time_limit)
1704
2154
 
1705
2155
  # assigned by `make_persistent`
1706
- self._workflow = None
1707
- self._value_group_idx = None
2156
+ self._workflow: Workflow | None = None
2157
+ self._value_group_idx: int | list[int] | None = None
1708
2158
 
1709
2159
  # user-specified resource parameters:
1710
2160
  self._scratch = scratch
@@ -1717,8 +2167,13 @@ class ResourceSpec(JSONLike):
1717
2167
  self._shell = self._process_string(shell)
1718
2168
  self._os_name = self._process_string(os_name)
1719
2169
  self._environments = environments
2170
+ self._resources_id = resources_id
2171
+ self._skip_downstream_on_failure = skip_downstream_on_failure
1720
2172
  self._use_job_array = use_job_array
1721
2173
  self._max_array_items = max_array_items
2174
+ self._write_app_logs = write_app_logs
2175
+ self._combine_jobscript_std = combine_jobscript_std
2176
+ self._combine_scripts = combine_scripts
1722
2177
  self._time_limit = time_limit
1723
2178
  self._scheduler_args = scheduler_args
1724
2179
  self._shell_args = shell_args
@@ -1733,7 +2188,7 @@ class ResourceSpec(JSONLike):
1733
2188
  self._SLURM_num_nodes = SLURM_num_nodes
1734
2189
  self._SLURM_num_cpus_per_task = SLURM_num_cpus_per_task
1735
2190
 
1736
- def __deepcopy__(self, memo):
2191
+ def __deepcopy__(self, memo: dict[int, Any]) -> Self:
1737
2192
  kwargs = copy.deepcopy(self.to_dict(), memo)
1738
2193
  _value_group_idx = kwargs.pop("value_group_idx", None)
1739
2194
  obj = self.__class__(**kwargs)
@@ -1743,65 +2198,60 @@ class ResourceSpec(JSONLike):
1743
2198
 
1744
2199
  def __repr__(self):
1745
2200
  param_strs = ""
1746
- for i in self.ALLOWED_PARAMETERS:
1747
- i_str = ""
2201
+ for param in self.ALLOWED_PARAMETERS:
1748
2202
  try:
1749
- i_val = getattr(self, i)
2203
+ i_val = getattr(self, param)
1750
2204
  except WorkflowParameterMissingError:
1751
- pass
1752
- else:
1753
- if i_val is not None:
1754
- i_str = f", {i}={i_val!r}"
1755
-
1756
- param_strs += i_str
2205
+ continue
2206
+ if i_val is not None:
2207
+ param_strs += f", {param}={i_val!r}"
1757
2208
 
1758
2209
  return f"{self.__class__.__name__}(scope={self.scope}{param_strs})"
1759
2210
 
1760
- def __eq__(self, other) -> bool:
2211
+ def __eq__(self, other: Any) -> bool:
1761
2212
  if not isinstance(other, self.__class__):
1762
2213
  return False
1763
- if self.to_dict() == other.to_dict():
1764
- return True
1765
- return False
2214
+ return self.to_dict() == other.to_dict()
1766
2215
 
1767
2216
  @classmethod
1768
- def _json_like_constructor(cls, json_like):
2217
+ def _json_like_constructor(cls, json_like) -> Self:
1769
2218
  """Invoked by `JSONLike.from_json_like` instead of `__init__`."""
1770
2219
 
1771
2220
  _value_group_idx = json_like.pop("value_group_idx", None)
1772
2221
  try:
1773
2222
  obj = cls(**json_like)
1774
2223
  except TypeError:
1775
- given_keys = set(k for k in json_like.keys() if k != "scope")
1776
- bad_keys = given_keys - cls.ALLOWED_PARAMETERS
1777
- bad_keys_str = ", ".join(f'"{i}"' for i in bad_keys)
1778
- allowed_keys_str = ", ".join(f'"{i}"' for i in cls.ALLOWED_PARAMETERS)
2224
+ given_keys = set(k for k in json_like if k != "scope")
2225
+ bad_keys = cls.__quoted(given_keys - cls.ALLOWED_PARAMETERS)
2226
+ good_keys = cls._allowed_params_quoted()
1779
2227
  raise UnknownResourceSpecItemError(
1780
- f"The following resource item names are unknown: {bad_keys_str}. Allowed "
1781
- f"resource item names are: {allowed_keys_str}."
2228
+ f"The following resource item names are unknown: {bad_keys}. "
2229
+ f"Allowed resource item names are: {good_keys}."
1782
2230
  )
1783
2231
  obj._value_group_idx = _value_group_idx
1784
2232
 
1785
2233
  return obj
1786
2234
 
1787
2235
  @property
1788
- def normalised_resources_path(self):
2236
+ def normalised_resources_path(self) -> str:
1789
2237
  """
1790
2238
  Standard name of this resource spec.
1791
2239
  """
1792
- return self.scope.to_string()
2240
+ scope = self.scope
2241
+ assert scope is not None
2242
+ return scope.to_string()
1793
2243
 
1794
2244
  @property
1795
- def normalised_path(self):
2245
+ def normalised_path(self) -> str:
1796
2246
  """
1797
2247
  Full name of this resource spec.
1798
2248
  """
1799
2249
  return f"resources.{self.normalised_resources_path}"
1800
2250
 
1801
- def to_dict(self):
1802
- out = super().to_dict()
1803
- if "_workflow" in out:
1804
- del out["_workflow"]
2251
+ @override
2252
+ def _postprocess_to_dict(self, d: dict[str, Any]) -> dict[str, Any]:
2253
+ out = super()._postprocess_to_dict(d)
2254
+ out.pop("_workflow", None)
1805
2255
 
1806
2256
  if self._value_group_idx is not None:
1807
2257
  # only store pointer to persistent data:
@@ -1819,9 +2269,13 @@ class ResourceSpec(JSONLike):
1819
2269
  out = {k: v for k, v in out.items() if v is not None}
1820
2270
  return out
1821
2271
 
2272
+ @classmethod
2273
+ def __is_Workflow(cls, value) -> TypeIs[Workflow]:
2274
+ return isinstance(value, cls._app.Workflow)
2275
+
1822
2276
  def make_persistent(
1823
- self, workflow: app.Workflow, source: Dict
1824
- ) -> Tuple[str, List[int], bool]:
2277
+ self, workflow: ResourcePersistingWorkflow, source: ParamSource
2278
+ ) -> tuple[str, list[int | list[int]], bool]:
1825
2279
  """Save to a persistent workflow.
1826
2280
 
1827
2281
  Returns
@@ -1830,6 +2284,9 @@ class ResourceSpec(JSONLike):
1830
2284
  contains the indices of the parameter data Zarr groups where the data is
1831
2285
  stored.
1832
2286
 
2287
+ Note
2288
+ ----
2289
+ May modify the internal state of this object.
1833
2290
  """
1834
2291
 
1835
2292
  if self._value_group_idx is not None:
@@ -1845,7 +2302,8 @@ class ResourceSpec(JSONLike):
1845
2302
  data_ref = workflow._add_parameter_data(self._get_members(), source=source)
1846
2303
  is_new = True
1847
2304
  self._value_group_idx = data_ref
1848
- self._workflow = workflow
2305
+ if self.__is_Workflow(workflow):
2306
+ self._workflow = workflow
1849
2307
 
1850
2308
  self._num_cores = None
1851
2309
  self._scratch = None
@@ -1853,11 +2311,16 @@ class ResourceSpec(JSONLike):
1853
2311
  self._shell = None
1854
2312
  self._use_job_array = None
1855
2313
  self._max_array_items = None
2314
+ self._write_app_logs = None
2315
+ self._combine_jobscript_std = None
2316
+ self._combine_scripts = None
1856
2317
  self._time_limit = None
1857
2318
  self._scheduler_args = None
1858
2319
  self._shell_args = None
1859
2320
  self._os_name = None
1860
2321
  self._environments = None
2322
+ self._resources_id = None
2323
+ self._skip_downstream_on_failure = None
1861
2324
 
1862
2325
  return (self.normalised_path, [data_ref], is_new)
1863
2326
 
@@ -1868,18 +2331,18 @@ class ResourceSpec(JSONLike):
1868
2331
  kwargs[name] = getattr(self, name)
1869
2332
  return self.__class__(**kwargs)
1870
2333
 
1871
- def _get_value(self, value_name=None):
1872
- if self._value_group_idx is not None:
1873
- val = self.workflow.get_parameter_data(self._value_group_idx)
2334
+ def _get_value(self, value_name: str | None = None):
2335
+ if self._value_group_idx is not None and self.workflow:
2336
+ val = self.workflow.get_parameter_data(cast("int", self._value_group_idx))
1874
2337
  else:
1875
2338
  val = self._get_members()
1876
- if value_name:
1877
- val = val.get(value_name)
2339
+ if value_name is not None and val is not None:
2340
+ return val.get(value_name)
1878
2341
 
1879
2342
  return val
1880
2343
 
1881
2344
  @staticmethod
1882
- def _process_string(value: Union[str, None]):
2345
+ def _process_string(value: str | None):
1883
2346
  return value.lower().strip() if value else value
1884
2347
 
1885
2348
  def _setter_persistent_check(self):
@@ -1889,7 +2352,7 @@ class ResourceSpec(JSONLike):
1889
2352
  )
1890
2353
 
1891
2354
  @property
1892
- def scratch(self):
2355
+ def scratch(self) -> str | None:
1893
2356
  """
1894
2357
  Which scratch space to use.
1895
2358
 
@@ -1900,164 +2363,182 @@ class ResourceSpec(JSONLike):
1900
2363
  return self._get_value("scratch")
1901
2364
 
1902
2365
  @property
1903
- def parallel_mode(self):
2366
+ def parallel_mode(self) -> ParallelMode | None:
1904
2367
  """
1905
2368
  Which parallel mode to use.
1906
2369
  """
1907
2370
  return self._get_value("parallel_mode")
1908
2371
 
1909
2372
  @property
1910
- def num_cores(self):
2373
+ def num_cores(self) -> int | None:
1911
2374
  """
1912
2375
  How many cores to request.
1913
2376
  """
1914
2377
  return self._get_value("num_cores")
1915
2378
 
1916
2379
  @property
1917
- def num_cores_per_node(self):
2380
+ def num_cores_per_node(self) -> int | None:
1918
2381
  """
1919
2382
  How many cores per compute node to request.
1920
2383
  """
1921
2384
  return self._get_value("num_cores_per_node")
1922
2385
 
1923
2386
  @property
1924
- def num_nodes(self):
2387
+ def num_nodes(self) -> int | None:
1925
2388
  """
1926
2389
  How many compute nodes to request.
1927
2390
  """
1928
2391
  return self._get_value("num_nodes")
1929
2392
 
1930
2393
  @property
1931
- def num_threads(self):
2394
+ def num_threads(self) -> int | None:
1932
2395
  """
1933
2396
  How many threads to request.
1934
2397
  """
1935
2398
  return self._get_value("num_threads")
1936
2399
 
1937
2400
  @property
1938
- def scheduler(self):
2401
+ def scheduler(self) -> str | None:
1939
2402
  """
1940
2403
  Which scheduler to use.
1941
2404
  """
1942
2405
  return self._get_value("scheduler")
1943
2406
 
1944
2407
  @scheduler.setter
1945
- def scheduler(self, value):
2408
+ def scheduler(self, value: str | None):
1946
2409
  self._setter_persistent_check()
1947
- value = self._process_string(value)
1948
- self._scheduler = value
2410
+ self._scheduler = self._process_string(value)
1949
2411
 
1950
2412
  @property
1951
- def shell(self):
2413
+ def shell(self) -> str | None:
1952
2414
  """
1953
2415
  Which system shell to use.
1954
2416
  """
1955
2417
  return self._get_value("shell")
1956
2418
 
1957
2419
  @shell.setter
1958
- def shell(self, value):
2420
+ def shell(self, value: str | None):
1959
2421
  self._setter_persistent_check()
1960
- value = self._process_string(value)
1961
- self._shell = value
2422
+ self._shell = self._process_string(value)
1962
2423
 
1963
2424
  @property
1964
- def use_job_array(self):
2425
+ def use_job_array(self) -> bool:
1965
2426
  """
1966
2427
  Whether to use array jobs.
1967
2428
  """
1968
2429
  return self._get_value("use_job_array")
1969
2430
 
1970
2431
  @property
1971
- def max_array_items(self):
2432
+ def max_array_items(self) -> int | None:
1972
2433
  """
1973
2434
  If using array jobs, up to how many items should be in the job array.
1974
2435
  """
1975
2436
  return self._get_value("max_array_items")
1976
2437
 
1977
2438
  @property
1978
- def time_limit(self):
2439
+ def write_app_logs(self) -> bool:
2440
+ return self._get_value("write_app_logs")
2441
+
2442
+ @property
2443
+ def combine_jobscript_std(self) -> bool:
2444
+ return self._get_value("combine_jobscript_std")
2445
+
2446
+ @property
2447
+ def combine_scripts(self) -> bool:
2448
+ return self._get_value("combine_scripts")
2449
+
2450
+ @property
2451
+ def time_limit(self) -> str | None:
1979
2452
  """
1980
2453
  How long to run for.
1981
2454
  """
1982
2455
  return self._get_value("time_limit")
1983
2456
 
1984
2457
  @property
1985
- def scheduler_args(self):
2458
+ def scheduler_args(self) -> Mapping: # TODO: TypedDict
1986
2459
  """
1987
2460
  Additional arguments to pass to the scheduler.
1988
2461
  """
1989
2462
  return self._get_value("scheduler_args")
1990
2463
 
1991
2464
  @property
1992
- def shell_args(self):
2465
+ def shell_args(self) -> Mapping | None: # TODO: TypedDict
1993
2466
  """
1994
2467
  Additional arguments to pass to the shell.
1995
2468
  """
1996
2469
  return self._get_value("shell_args")
1997
2470
 
1998
2471
  @property
1999
- def os_name(self):
2472
+ def os_name(self) -> str:
2000
2473
  """
2001
2474
  Which OS to use.
2002
2475
  """
2003
2476
  return self._get_value("os_name")
2004
2477
 
2478
+ @os_name.setter
2479
+ def os_name(self, value: str):
2480
+ self._setter_persistent_check()
2481
+ self._os_name = self._process_string(value)
2482
+
2005
2483
  @property
2006
- def environments(self):
2484
+ def environments(self) -> Mapping | None: # TODO: TypedDict
2007
2485
  """
2008
2486
  Which execution environments to use.
2009
2487
  """
2010
2488
  return self._get_value("environments")
2011
2489
 
2012
2490
  @property
2013
- def SGE_parallel_env(self):
2491
+ def resources_id(self) -> int:
2492
+ return self._get_value("resources_id")
2493
+
2494
+ @property
2495
+ def skip_downstream_on_failure(self) -> bool:
2496
+ return self._get_value("skip_downstream_on_failure")
2497
+
2498
+ @property
2499
+ def SGE_parallel_env(self) -> str | None:
2014
2500
  """
2015
2501
  Which SGE parallel environment to request.
2016
2502
  """
2017
2503
  return self._get_value("SGE_parallel_env")
2018
2504
 
2019
2505
  @property
2020
- def SLURM_partition(self):
2506
+ def SLURM_partition(self) -> str | None:
2021
2507
  """
2022
2508
  Which SLURM partition to request.
2023
2509
  """
2024
2510
  return self._get_value("SLURM_partition")
2025
2511
 
2026
2512
  @property
2027
- def SLURM_num_tasks(self):
2513
+ def SLURM_num_tasks(self) -> int | None:
2028
2514
  """
2029
2515
  How many SLURM tasks to request.
2030
2516
  """
2031
2517
  return self._get_value("SLURM_num_tasks")
2032
2518
 
2033
2519
  @property
2034
- def SLURM_num_tasks_per_node(self):
2520
+ def SLURM_num_tasks_per_node(self) -> int | None:
2035
2521
  """
2036
2522
  How many SLURM tasks per compute node to request.
2037
2523
  """
2038
2524
  return self._get_value("SLURM_num_tasks_per_node")
2039
2525
 
2040
2526
  @property
2041
- def SLURM_num_nodes(self):
2527
+ def SLURM_num_nodes(self) -> int | None:
2042
2528
  """
2043
2529
  How many compute nodes to request.
2044
2530
  """
2045
2531
  return self._get_value("SLURM_num_nodes")
2046
2532
 
2047
2533
  @property
2048
- def SLURM_num_cpus_per_task(self):
2534
+ def SLURM_num_cpus_per_task(self) -> int | None:
2049
2535
  """
2050
2536
  How many CPU cores to ask for per SLURM task.
2051
2537
  """
2052
2538
  return self._get_value("SLURM_num_cpus_per_task")
2053
2539
 
2054
- @os_name.setter
2055
- def os_name(self, value):
2056
- self._setter_persistent_check()
2057
- self._os_name = self._process_string(value)
2058
-
2059
2540
  @property
2060
- def workflow(self):
2541
+ def workflow(self) -> Workflow | None:
2061
2542
  """
2062
2543
  The workflow owning this resource spec.
2063
2544
  """
@@ -2066,7 +2547,8 @@ class ResourceSpec(JSONLike):
2066
2547
 
2067
2548
  elif self.element_set:
2068
2549
  # element-set-level resources
2069
- return self.element_set.task_template.workflow_template.workflow
2550
+ wt = self.element_set.task_template.workflow_template
2551
+ return wt.workflow if wt else None
2070
2552
 
2071
2553
  elif self.workflow_template:
2072
2554
  # template-level resources
@@ -2079,47 +2561,29 @@ class ResourceSpec(JSONLike):
2079
2561
  f"creating the workflow object."
2080
2562
  )
2081
2563
 
2564
+ return None
2565
+
2082
2566
  @property
2083
- def element_set(self):
2567
+ def element_set(self) -> ElementSet | None:
2084
2568
  """
2085
2569
  The element set that will use this resource spec.
2086
2570
  """
2571
+ if not self._resource_list:
2572
+ return None
2087
2573
  return self._resource_list.element_set
2088
2574
 
2089
2575
  @property
2090
- def workflow_template(self):
2576
+ def workflow_template(self) -> WorkflowTemplate | None:
2091
2577
  """
2092
2578
  The workflow template that will use this resource spec.
2093
2579
  """
2580
+ if not self._resource_list:
2581
+ return None
2094
2582
  return self._resource_list.workflow_template
2095
2583
 
2096
2584
 
2097
- class InputSourceType(enum.Enum):
2098
- """
2099
- The types if input sources.
2100
- """
2101
-
2102
- #: Input source is an import.
2103
- IMPORT = 0
2104
- #: Input source is local.
2105
- LOCAL = 1
2106
- #: Input source is a default.
2107
- DEFAULT = 2
2108
- #: Input source is a task.
2109
- TASK = 3
2110
-
2111
-
2112
- class TaskSourceType(enum.Enum):
2113
- """
2114
- The types of task-based input sources.
2115
- """
2116
-
2117
- #: Input source is a task input.
2118
- INPUT = 0
2119
- #: Input source is a task output.
2120
- OUTPUT = 1
2121
- #: Input source is unspecified.
2122
- ANY = 2
2585
+ #: How to specify a selection rule.
2586
+ Where: TypeAlias = "RuleArgs | Rule | Sequence[RuleArgs | Rule] | ElementFilter"
2123
2587
 
2124
2588
 
2125
2589
  class InputSource(JSONLike):
@@ -2144,7 +2608,7 @@ class InputSource(JSONLike):
2144
2608
  Filtering rules.
2145
2609
  """
2146
2610
 
2147
- _child_objects = (
2611
+ _child_objects: ClassVar[tuple[ChildObjectSpec, ...]] = (
2148
2612
  ChildObjectSpec(
2149
2613
  name="source_type",
2150
2614
  json_like_name="type",
@@ -2153,55 +2617,61 @@ class InputSource(JSONLike):
2153
2617
  ),
2154
2618
  )
2155
2619
 
2620
+ @classmethod
2621
+ def __is_ElementFilter(cls, value) -> TypeIs[ElementFilter]:
2622
+ return isinstance(value, cls._app.ElementFilter)
2623
+
2624
+ @classmethod
2625
+ def __is_Rule(cls, value) -> TypeIs[Rule]:
2626
+ return isinstance(value, cls._app.Rule)
2627
+
2156
2628
  def __init__(
2157
2629
  self,
2158
- source_type,
2159
- import_ref=None,
2160
- task_ref=None,
2161
- task_source_type=None,
2162
- element_iters=None,
2163
- path=None,
2164
- where: Optional[
2165
- Union[dict, app.Rule, List[dict], List[app.Rule], app.ElementFilter]
2166
- ] = None,
2630
+ source_type: InputSourceType | str,
2631
+ import_ref: int | None = None,
2632
+ task_ref: int | None = None,
2633
+ task_source_type: TaskSourceType | str | None = None,
2634
+ element_iters: list[int] | None = None,
2635
+ path: str | None = None,
2636
+ where: Where | None = None,
2167
2637
  ):
2168
- if where is not None and not isinstance(where, ElementFilter):
2169
- rules = where
2170
- if not isinstance(rules, list):
2171
- rules = [rules]
2172
- for idx, i in enumerate(rules):
2173
- if not isinstance(i, Rule):
2174
- rules[idx] = app.Rule(**i)
2175
- where = app.ElementFilter(rules=rules)
2638
+ if where is None or self.__is_ElementFilter(where):
2639
+ #: Filtering rules.
2640
+ self.where: ElementFilter | None = where
2641
+ else:
2642
+ self.where = self._app.ElementFilter(
2643
+ rules=[
2644
+ rule if self.__is_Rule(rule) else self._app.Rule(**rule)
2645
+ for rule in (where if isinstance(where, Sequence) else [where])
2646
+ ]
2647
+ )
2176
2648
 
2177
2649
  #: Type of the input source.
2178
- self.source_type = self._validate_source_type(source_type)
2650
+ self.source_type = get_enum_by_name_or_val(InputSourceType, source_type)
2179
2651
  #: Where the input comes from when the type is `IMPORT`.
2180
2652
  self.import_ref = import_ref
2181
2653
  #: Which task is this an input for? Used when the type is `TASK`.
2182
2654
  self.task_ref = task_ref
2183
2655
  #: Type of task source.
2184
- self.task_source_type = self._validate_task_source_type(task_source_type)
2656
+ self.task_source_type = get_enum_by_name_or_val(TaskSourceType, task_source_type)
2185
2657
  #: Which element iterations does this apply to?
2186
2658
  self.element_iters = element_iters
2187
- #: Filtering rules.
2188
- self.where = where
2189
2659
  #: Path to where this input goes.
2190
2660
  self.path = path
2191
2661
 
2192
2662
  if self.source_type is InputSourceType.TASK:
2193
2663
  if self.task_ref is None:
2194
- raise ValueError(f"Must specify `task_ref` if `source_type` is TASK.")
2664
+ raise ValueError("Must specify `task_ref` if `source_type` is TASK.")
2195
2665
  if self.task_source_type is None:
2196
2666
  self.task_source_type = TaskSourceType.OUTPUT
2197
2667
 
2198
2668
  if self.source_type is InputSourceType.IMPORT and self.import_ref is None:
2199
- raise ValueError(f"Must specify `import_ref` if `source_type` is IMPORT.")
2669
+ raise ValueError("Must specify `import_ref` if `source_type` is IMPORT.")
2200
2670
 
2201
- def __eq__(self, other):
2671
+ def __eq__(self, other: Any):
2202
2672
  if not isinstance(other, self.__class__):
2203
2673
  return False
2204
- elif (
2674
+ return (
2205
2675
  self.source_type == other.source_type
2206
2676
  and self.import_ref == other.import_ref
2207
2677
  and self.task_ref == other.task_ref
@@ -2209,23 +2679,22 @@ class InputSource(JSONLike):
2209
2679
  and self.element_iters == other.element_iters
2210
2680
  and self.where == other.where
2211
2681
  and self.path == other.path
2212
- ):
2213
- return True
2214
- else:
2215
- return False
2682
+ )
2216
2683
 
2217
2684
  def __repr__(self) -> str:
2685
+ assert self.source_type
2218
2686
  cls_method_name = self.source_type.name.lower()
2219
2687
 
2220
- args_lst = []
2688
+ args_lst: list[str] = []
2221
2689
 
2222
2690
  if self.source_type is InputSourceType.IMPORT:
2223
2691
  cls_method_name += "_"
2224
2692
  args_lst.append(f"import_ref={self.import_ref}")
2225
2693
 
2226
2694
  elif self.source_type is InputSourceType.TASK:
2227
- args_lst += (
2228
- f"task_ref={self.task_ref}",
2695
+ assert self.task_source_type
2696
+ args_lst.append(f"task_ref={self.task_ref}")
2697
+ args_lst.append(
2229
2698
  f"task_source_type={self.task_source_type.name.lower()!r}",
2230
2699
  )
2231
2700
 
@@ -2240,15 +2709,16 @@ class InputSource(JSONLike):
2240
2709
 
2241
2710
  return out
2242
2711
 
2243
- def get_task(self, workflow):
2712
+ def get_task(self, workflow: Workflow) -> WorkflowTask | None:
2244
2713
  """If source_type is task, then return the referenced task from the given
2245
2714
  workflow."""
2246
2715
  if self.source_type is InputSourceType.TASK:
2247
- for task in workflow.tasks:
2248
- if task.insert_ID == self.task_ref:
2249
- return task
2716
+ return next(
2717
+ (task for task in workflow.tasks if task.insert_ID == self.task_ref), None
2718
+ )
2719
+ return None
2250
2720
 
2251
- def is_in(self, other_input_sources: List[app.InputSource]) -> Union[None, int]:
2721
+ def is_in(self, other_input_sources: Sequence[InputSource]) -> int | None:
2252
2722
  """Check if this input source is in a list of other input sources, without
2253
2723
  considering the `element_iters` and `where` attributes."""
2254
2724
 
@@ -2263,51 +2733,38 @@ class InputSource(JSONLike):
2263
2733
  return idx
2264
2734
  return None
2265
2735
 
2266
- def to_string(self):
2736
+ def to_string(self) -> str:
2267
2737
  """
2268
2738
  Render this input source as a string.
2269
2739
  """
2270
2740
  out = [self.source_type.name.lower()]
2271
2741
  if self.source_type is InputSourceType.TASK:
2272
- out += [str(self.task_ref), self.task_source_type.name.lower()]
2742
+ assert self.task_source_type
2743
+ out.append(str(self.task_ref))
2744
+ out.append(self.task_source_type.name.lower())
2273
2745
  if self.element_iters is not None:
2274
- out += ["[" + ",".join(f"{i}" for i in self.element_iters) + "]"]
2746
+ out.append(f'[{",".join(map(str, self.element_iters))}]')
2275
2747
  elif self.source_type is InputSourceType.IMPORT:
2276
- out += [str(self.import_ref)]
2748
+ out.append(str(self.import_ref))
2277
2749
  return ".".join(out)
2278
2750
 
2279
- @staticmethod
2280
- def _validate_source_type(src_type):
2281
- if src_type is None:
2282
- return None
2283
- if isinstance(src_type, InputSourceType):
2284
- return src_type
2285
- try:
2286
- src_type = getattr(InputSourceType, src_type.upper())
2287
- except AttributeError:
2288
- raise ValueError(
2289
- f"InputSource `source_type` specified as {src_type!r}, but "
2290
- f"must be one of: {[i.name for i in InputSourceType]!r}."
2291
- )
2292
- return src_type
2293
-
2294
2751
  @classmethod
2295
- def _validate_task_source_type(cls, task_src_type):
2752
+ def _validate_task_source_type(cls, task_src_type) -> None | TaskSourceType:
2296
2753
  if task_src_type is None:
2297
2754
  return None
2298
2755
  if isinstance(task_src_type, TaskSourceType):
2299
2756
  return task_src_type
2300
2757
  try:
2301
- task_source_type = getattr(cls.app.TaskSourceType, task_src_type.upper())
2758
+ task_source_type = getattr(cls._app.TaskSourceType, task_src_type.upper())
2302
2759
  except AttributeError:
2303
2760
  raise ValueError(
2304
2761
  f"InputSource `task_source_type` specified as {task_src_type!r}, but "
2305
- f"must be one of: {[i.name for i in TaskSourceType]!r}."
2762
+ f"must be one of: {TaskSourceType.names!r}."
2306
2763
  )
2307
2764
  return task_source_type
2308
2765
 
2309
2766
  @classmethod
2310
- def from_string(cls, str_defn):
2767
+ def from_string(cls, str_defn: str) -> Self:
2311
2768
  """Parse a dot-delimited string definition of an InputSource.
2312
2769
 
2313
2770
  Parameter
@@ -2322,44 +2779,49 @@ class InputSource(JSONLike):
2322
2779
  local
2323
2780
  default
2324
2781
  import.[import_ref]
2325
-
2326
2782
  """
2327
2783
  return cls(**cls._parse_from_string(str_defn))
2328
2784
 
2329
- @classmethod
2330
- def _parse_from_string(cls, str_defn):
2785
+ @staticmethod
2786
+ def _parse_from_string(str_defn: str) -> dict[str, Any]:
2787
+ """Parse a dot-delimited string definition of an InputSource.
2788
+
2789
+ Examples
2790
+ --------
2791
+ task.[task_ref].input
2792
+ task.[task_ref].output
2793
+ local
2794
+ default
2795
+ import.[import_ref]
2796
+ """
2331
2797
  parts = str_defn.split(".")
2332
- source_type = cls._validate_source_type(parts[0])
2333
- task_ref = None
2334
- task_source_type = None
2335
- import_ref = None
2798
+ source_type = get_enum_by_name_or_val(InputSourceType, parts[0])
2799
+ task_ref: int | None = None
2800
+ task_source_type: TaskSourceType | None = None
2801
+ import_ref: int | None = None
2336
2802
  if (
2337
2803
  (
2338
- source_type
2339
- in (cls.app.InputSourceType.LOCAL, cls.app.InputSourceType.DEFAULT)
2804
+ source_type in (InputSourceType.LOCAL, InputSourceType.DEFAULT)
2340
2805
  and len(parts) > 1
2341
2806
  )
2342
- or (source_type is cls.app.InputSourceType.TASK and len(parts) > 3)
2343
- or (source_type is cls.app.InputSourceType.IMPORT and len(parts) > 2)
2807
+ or (source_type is InputSourceType.TASK and len(parts) > 3)
2808
+ or (source_type is InputSourceType.IMPORT and len(parts) > 2)
2344
2809
  ):
2345
2810
  raise ValueError(f"InputSource string not understood: {str_defn!r}.")
2346
2811
 
2347
- if source_type is cls.app.InputSourceType.TASK:
2812
+ if source_type is InputSourceType.TASK:
2348
2813
  # TODO: does this include element_iters?
2349
- task_ref = parts[1]
2350
2814
  try:
2351
- task_ref = int(task_ref)
2815
+ task_ref = int(parts[1])
2352
2816
  except ValueError:
2353
2817
  pass
2354
2818
  try:
2355
- task_source_type_str = parts[2]
2819
+ task_source_type = get_enum_by_name_or_val(TaskSourceType, parts[2])
2356
2820
  except IndexError:
2357
- task_source_type_str = cls.app.TaskSourceType.OUTPUT
2358
- task_source_type = cls._validate_task_source_type(task_source_type_str)
2359
- elif source_type is cls.app.InputSourceType.IMPORT:
2360
- import_ref = parts[1]
2821
+ task_source_type = TaskSourceType.OUTPUT
2822
+ elif source_type is InputSourceType.IMPORT:
2361
2823
  try:
2362
- import_ref = int(import_ref)
2824
+ import_ref = int(parts[1])
2363
2825
  except ValueError:
2364
2826
  pass
2365
2827
 
@@ -2377,9 +2839,14 @@ class InputSource(JSONLike):
2377
2839
  return super().from_json_like(json_like, shared_data)
2378
2840
 
2379
2841
  @classmethod
2380
- def import_(cls, import_ref, element_iters=None, where=None):
2842
+ def import_(
2843
+ cls,
2844
+ import_ref: int,
2845
+ element_iters: list[int] | None = None,
2846
+ where: Where | None = None,
2847
+ ) -> Self:
2381
2848
  """
2382
- Make an instnace of an input source that is an import.
2849
+ Make an instance of an input source that is an import.
2383
2850
 
2384
2851
  Parameters
2385
2852
  ----------
@@ -2391,30 +2858,36 @@ class InputSource(JSONLike):
2391
2858
  Filtering rule.
2392
2859
  """
2393
2860
  return cls(
2394
- source_type=cls.app.InputSourceType.IMPORT,
2861
+ source_type=InputSourceType.IMPORT,
2395
2862
  import_ref=import_ref,
2396
2863
  element_iters=element_iters,
2397
2864
  where=where,
2398
2865
  )
2399
2866
 
2400
2867
  @classmethod
2401
- def local(cls):
2868
+ def local(cls) -> Self:
2402
2869
  """
2403
- Make an instnace of an input source that is local.
2870
+ Make an instance of an input source that is local.
2404
2871
  """
2405
- return cls(source_type=cls.app.InputSourceType.LOCAL)
2872
+ return cls(source_type=InputSourceType.LOCAL)
2406
2873
 
2407
2874
  @classmethod
2408
- def default(cls):
2875
+ def default(cls) -> Self:
2409
2876
  """
2410
- Make an instnace of an input source that is default.
2877
+ Make an instance of an input source that is default.
2411
2878
  """
2412
- return cls(source_type=cls.app.InputSourceType.DEFAULT)
2879
+ return cls(source_type=InputSourceType.DEFAULT)
2413
2880
 
2414
2881
  @classmethod
2415
- def task(cls, task_ref, task_source_type=None, element_iters=None, where=None):
2882
+ def task(
2883
+ cls,
2884
+ task_ref: int,
2885
+ task_source_type: TaskSourceType | str | None = None,
2886
+ element_iters: list[int] | None = None,
2887
+ where: Where | None = None,
2888
+ ) -> Self:
2416
2889
  """
2417
- Make an instnace of an input source that is a task.
2890
+ Make an instance of an input source that is a task.
2418
2891
 
2419
2892
  Parameters
2420
2893
  ----------
@@ -2427,12 +2900,12 @@ class InputSource(JSONLike):
2427
2900
  where:
2428
2901
  Filtering rule.
2429
2902
  """
2430
- if not task_source_type:
2431
- task_source_type = cls.app.TaskSourceType.OUTPUT
2432
2903
  return cls(
2433
- source_type=cls.app.InputSourceType.TASK,
2904
+ source_type=InputSourceType.TASK,
2434
2905
  task_ref=task_ref,
2435
- task_source_type=cls._validate_task_source_type(task_source_type),
2906
+ task_source_type=get_enum_by_name_or_val(
2907
+ TaskSourceType, task_source_type or TaskSourceType.OUTPUT
2908
+ ),
2436
2909
  where=where,
2437
2910
  element_iters=element_iters,
2438
2911
  )