hpcflow-new2 0.2.0a162__py3-none-any.whl → 0.2.0a164__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 (37) hide show
  1. hpcflow/_version.py +1 -1
  2. hpcflow/data/scripts/main_script_test_direct_in_direct_out_env_spec.py +7 -0
  3. hpcflow/sdk/app.py +29 -42
  4. hpcflow/sdk/cli.py +1 -1
  5. hpcflow/sdk/core/actions.py +87 -21
  6. hpcflow/sdk/core/command_files.py +6 -4
  7. hpcflow/sdk/core/commands.py +21 -2
  8. hpcflow/sdk/core/element.py +39 -8
  9. hpcflow/sdk/core/errors.py +16 -0
  10. hpcflow/sdk/core/object_list.py +26 -14
  11. hpcflow/sdk/core/parameters.py +21 -3
  12. hpcflow/sdk/core/task.py +111 -4
  13. hpcflow/sdk/core/task_schema.py +17 -2
  14. hpcflow/sdk/core/test_utils.py +5 -2
  15. hpcflow/sdk/core/workflow.py +93 -5
  16. hpcflow/sdk/data/workflow_spec_schema.yaml +14 -58
  17. hpcflow/sdk/demo/cli.py +1 -1
  18. hpcflow/sdk/persistence/base.py +6 -0
  19. hpcflow/sdk/persistence/zarr.py +2 -0
  20. hpcflow/sdk/submission/submission.py +21 -10
  21. hpcflow/tests/scripts/test_main_scripts.py +60 -0
  22. hpcflow/tests/unit/test_action.py +186 -0
  23. hpcflow/tests/unit/test_element.py +27 -25
  24. hpcflow/tests/unit/test_element_set.py +32 -0
  25. hpcflow/tests/unit/test_parameter.py +11 -9
  26. hpcflow/tests/unit/test_persistence.py +4 -1
  27. hpcflow/tests/unit/test_resources.py +7 -9
  28. hpcflow/tests/unit/test_schema_input.py +8 -8
  29. hpcflow/tests/unit/test_task.py +26 -27
  30. hpcflow/tests/unit/test_task_schema.py +39 -8
  31. hpcflow/tests/unit/test_value_sequence.py +5 -0
  32. hpcflow/tests/unit/test_workflow.py +4 -9
  33. hpcflow/tests/unit/test_workflow_template.py +122 -1
  34. {hpcflow_new2-0.2.0a162.dist-info → hpcflow_new2-0.2.0a164.dist-info}/METADATA +1 -1
  35. {hpcflow_new2-0.2.0a162.dist-info → hpcflow_new2-0.2.0a164.dist-info}/RECORD +37 -36
  36. {hpcflow_new2-0.2.0a162.dist-info → hpcflow_new2-0.2.0a164.dist-info}/WHEEL +0 -0
  37. {hpcflow_new2-0.2.0a162.dist-info → hpcflow_new2-0.2.0a164.dist-info}/entry_points.txt +0 -0
@@ -4,6 +4,10 @@ from types import SimpleNamespace
4
4
  from hpcflow.sdk.core.json_like import ChildObjectSpec, JSONLike
5
5
 
6
6
 
7
+ class ObjectListMultipleMatchError(ValueError):
8
+ pass
9
+
10
+
7
11
  class ObjectList(JSONLike):
8
12
  """A list-like class that provides item access via a `get` method according to
9
13
  attributes or dict-keys.
@@ -116,14 +120,22 @@ class ObjectList(JSONLike):
116
120
  if not result:
117
121
  available = []
118
122
  for obj in self._objects:
119
- available.append({k: getattr(obj, k) for k in kwargs})
123
+ attr_vals = {}
124
+ for k in kwargs:
125
+ try:
126
+ attr_vals[k] = self._get_obj_attr(obj, k)
127
+ except (AttributeError, KeyError):
128
+ continue
129
+ available.append(attr_vals)
120
130
  raise ValueError(
121
131
  f"No {self._descriptor} objects with attributes: {kwargs}. Available "
122
132
  f"objects have attributes: {tuple(available)!r}."
123
133
  )
124
134
 
125
135
  elif len(result) > 1:
126
- raise ValueError(f"Multiple objects with attributes: {kwargs}.")
136
+ raise ObjectListMultipleMatchError(
137
+ f"Multiple objects with attributes: {kwargs}."
138
+ )
127
139
 
128
140
  return result[0]
129
141
 
@@ -571,23 +583,23 @@ class ResourceList(ObjectList):
571
583
  def get_scopes(self):
572
584
  return tuple(i.scope for i in self._objects)
573
585
 
574
- def merge_template_resources(self, temp_res_lst):
575
- """Merge lower-precedence template-level resources into this resource list."""
576
- for scope_i in temp_res_lst.get_scopes():
586
+ def merge_other(self, other):
587
+ """Merge lower-precedence other resource list into this resource list."""
588
+ for scope_i in other.get_scopes():
577
589
  try:
578
- es_scoped = self.get(scope=scope_i)
590
+ self_scoped = self.get(scope=scope_i)
579
591
  except ValueError:
580
- in_es = False
592
+ in_self = False
581
593
  else:
582
- in_es = True
594
+ in_self = True
583
595
 
584
- temp_res_scoped = temp_res_lst.get(scope=scope_i)
585
- if in_es:
586
- for k, v in temp_res_scoped._get_members().items():
587
- if getattr(es_scoped, k) is None:
588
- setattr(es_scoped, f"_{k}", v)
596
+ other_scoped = other.get(scope=scope_i)
597
+ if in_self:
598
+ for k, v in other_scoped._get_members().items():
599
+ if getattr(self_scoped, k) is None:
600
+ setattr(self_scoped, f"_{k}", copy.deepcopy(v))
589
601
  else:
590
- self.add_object(copy.deepcopy(temp_res_scoped))
602
+ self.add_object(copy.deepcopy(other_scoped))
591
603
 
592
604
 
593
605
  def index(obj_lst, obj):
@@ -678,9 +678,11 @@ class ValueSequence(JSONLike):
678
678
  )
679
679
  path_l = path.lower()
680
680
  path_split = path_l.split(".")
681
- if not path_split[0] in ("inputs", "resources"):
681
+ allowed_path_start = ("inputs", "resources", "environments", "env_preset")
682
+ if not path_split[0] in allowed_path_start:
682
683
  raise MalformedParameterPathError(
683
- f'`path` must start with "inputs", "outputs", or "resources", but given path '
684
+ f"`path` must start with one of: "
685
+ f'{", ".join(f"{i!r}" for i in allowed_path_start)}, but given path '
684
686
  f"is: {path!r}."
685
687
  )
686
688
 
@@ -703,7 +705,7 @@ class ValueSequence(JSONLike):
703
705
  elif label_from_path:
704
706
  label = label_from_path
705
707
 
706
- if path_split[0] == "resources":
708
+ elif path_split[0] == "resources":
707
709
  if label_from_path or label_arg:
708
710
  raise ValueError(
709
711
  f"{self.__class__.__name__} `label` argument ({label_arg!r}) and/or "
@@ -728,6 +730,14 @@ class ValueSequence(JSONLike):
728
730
  f"resource item names are: {allowed_keys_str}."
729
731
  )
730
732
 
733
+ elif path_split[0] == "environments":
734
+ # rewrite as a resources path:
735
+ path = f"resources.any.{path}"
736
+
737
+ # note: `env_preset` paths also need to be transformed into `resources` paths, but
738
+ # we cannot do that until the sequence is part of a task, since the available
739
+ # environment presets are defined in the task schema.
740
+
731
741
  return path, label
732
742
 
733
743
  def to_dict(self):
@@ -1330,6 +1340,7 @@ class ResourceSpec(JSONLike):
1330
1340
  "scheduler_args",
1331
1341
  "shell_args",
1332
1342
  "os_name",
1343
+ "environments",
1333
1344
  "SGE_parallel_env",
1334
1345
  "SLURM_partition",
1335
1346
  "SLURM_num_tasks",
@@ -1364,6 +1375,7 @@ class ResourceSpec(JSONLike):
1364
1375
  scheduler_args: Optional[Dict] = None,
1365
1376
  shell_args: Optional[Dict] = None,
1366
1377
  os_name: Optional[str] = None,
1378
+ environments: Optional[Dict] = None,
1367
1379
  SGE_parallel_env: Optional[str] = None,
1368
1380
  SLURM_partition: Optional[str] = None,
1369
1381
  SLURM_num_tasks: Optional[str] = None,
@@ -1392,6 +1404,7 @@ class ResourceSpec(JSONLike):
1392
1404
  self._scheduler = self._process_string(scheduler)
1393
1405
  self._shell = self._process_string(shell)
1394
1406
  self._os_name = self._process_string(os_name)
1407
+ self._environments = environments
1395
1408
  self._use_job_array = use_job_array
1396
1409
  self._max_array_items = max_array_items
1397
1410
  self._time_limit = time_limit
@@ -1526,6 +1539,7 @@ class ResourceSpec(JSONLike):
1526
1539
  self._scheduler_args = None
1527
1540
  self._shell_args = None
1528
1541
  self._os_name = None
1542
+ self._environments = None
1529
1543
 
1530
1544
  return (self.normalised_path, [data_ref], is_new)
1531
1545
 
@@ -1625,6 +1639,10 @@ class ResourceSpec(JSONLike):
1625
1639
  def os_name(self):
1626
1640
  return self._get_value("os_name")
1627
1641
 
1642
+ @property
1643
+ def environments(self):
1644
+ return self._get_value("environments")
1645
+
1628
1646
  @property
1629
1647
  def SGE_parallel_env(self):
1630
1648
  return self._get_value("SGE_parallel_env")
hpcflow/sdk/core/task.py CHANGED
@@ -25,6 +25,7 @@ from .errors import (
25
25
  TaskTemplateMultipleSchemaObjectives,
26
26
  TaskTemplateUnexpectedInput,
27
27
  TaskTemplateUnexpectedSequenceInput,
28
+ UnknownEnvironmentPresetError,
28
29
  UnrequiredInputSources,
29
30
  UnsetParameterDataError,
30
31
  )
@@ -126,8 +127,11 @@ class ElementSet(JSONLike):
126
127
  groups: Optional[List[app.ElementGroup]] = None,
127
128
  input_sources: Optional[Dict[str, app.InputSource]] = None,
128
129
  nesting_order: Optional[List] = None,
130
+ env_preset: Optional[str] = None,
131
+ environments: Optional[Dict[str, Dict[str, Any]]] = None,
129
132
  sourceable_elem_iters: Optional[List[int]] = None,
130
133
  allow_non_coincident_task_sources: Optional[bool] = False,
134
+ merge_envs: Optional[bool] = True,
131
135
  ):
132
136
  """
133
137
  Parameters
@@ -140,7 +144,10 @@ class ElementSet(JSONLike):
140
144
  If True, if more than one parameter is sourced from the same task, then allow
141
145
  these sources to come from distinct element sub-sets. If False (default),
142
146
  only the intersection of element sub-sets for all parameters are included.
143
-
147
+ merge_envs
148
+ If True, merge `environments` into `resources` using the "any" scope. If
149
+ False, `environments` are ignored. This is required on first initialisation,
150
+ but not on subsequent re-initialisation from a persistent workflow.
144
151
  """
145
152
 
146
153
  self.inputs = inputs or []
@@ -151,8 +158,11 @@ class ElementSet(JSONLike):
151
158
  self.sequences = sequences or []
152
159
  self.input_sources = input_sources or {}
153
160
  self.nesting_order = nesting_order or {}
161
+ self.env_preset = env_preset
162
+ self.environments = environments
154
163
  self.sourceable_elem_iters = sourceable_elem_iters
155
164
  self.allow_non_coincident_task_sources = allow_non_coincident_task_sources
165
+ self.merge_envs = merge_envs
156
166
 
157
167
  self._validate()
158
168
  self._set_parent_refs()
@@ -161,6 +171,18 @@ class ElementSet(JSONLike):
161
171
  self._defined_input_types = None # assigned on _task_template assignment
162
172
  self._element_local_idx_range = None # assigned by WorkflowTask._add_element_set
163
173
 
174
+ # merge `environments` into element set resources (this mutates `resources`, and
175
+ # should only happen on creation of the element set, not re-initialisation from a
176
+ # persistent workflow):
177
+ if self.environments and self.merge_envs:
178
+ envs_res = self.app.ResourceList(
179
+ [self.app.ResourceSpec(scope="any", environments=self.environments)]
180
+ )
181
+ self.resources.merge_other(envs_res)
182
+ self.merge_envs = False
183
+
184
+ # note: `env_preset` is merged into resources by the Task init.
185
+
164
186
  def __deepcopy__(self, memo):
165
187
  dct = self.to_dict()
166
188
  orig_inp = dct.pop("original_input_sources", None)
@@ -278,6 +300,10 @@ class ElementSet(JSONLike):
278
300
  f"provided for parameter {src_key!r}."
279
301
  )
280
302
 
303
+ # disallow both `env_preset` and `environments` specifications:
304
+ if self.env_preset and self.environments:
305
+ raise ValueError("Specify at most one of `env_preset` and `environments`.")
306
+
281
307
  def _validate_against_template(self):
282
308
  unexpected_types = (
283
309
  set(self.input_types) - self.task_template.all_schema_input_types
@@ -330,6 +356,8 @@ class ElementSet(JSONLike):
330
356
  groups=None,
331
357
  input_sources=None,
332
358
  nesting_order=None,
359
+ env_preset=None,
360
+ environments=None,
333
361
  element_sets=None,
334
362
  sourceable_elem_iters=None,
335
363
  ):
@@ -342,6 +370,8 @@ class ElementSet(JSONLike):
342
370
  groups,
343
371
  input_sources,
344
372
  nesting_order,
373
+ env_preset,
374
+ environments,
345
375
  )
346
376
  args_not_none = [i is not None for i in args]
347
377
 
@@ -520,9 +550,12 @@ class Task(JSONLike):
520
550
  sequences: Optional[List[app.ValueSequence]] = None,
521
551
  input_sources: Optional[Dict[str, app.InputSource]] = None,
522
552
  nesting_order: Optional[List] = None,
553
+ env_preset: Optional[str] = None,
554
+ environments: Optional[Dict[str, Dict[str, Any]]] = None,
523
555
  element_sets: Optional[List[app.ElementSet]] = None,
524
556
  output_labels: Optional[List[app.OutputLabel]] = None,
525
557
  sourceable_elem_iters: Optional[List[int]] = None,
558
+ merge_envs: Optional[bool] = True,
526
559
  ):
527
560
  """
528
561
  Parameters
@@ -532,7 +565,11 @@ class Task(JSONLike):
532
565
  schema names that uniquely identify a task schema. If strings are provided,
533
566
  the `TaskSchema` object will be fetched from the known task schemas loaded by
534
567
  the app configuration.
535
-
568
+ merge_envs
569
+ If True, merge environment presets (set via the element set `env_preset` key)
570
+ into `resources` using the "any" scope. If False, these presets are ignored.
571
+ This is required on first initialisation, but not on subsequent
572
+ re-initialisation from a persistent workflow.
536
573
  """
537
574
 
538
575
  # TODO: allow init via specifying objective and/or method and/or implementation
@@ -576,10 +613,13 @@ class Task(JSONLike):
576
613
  groups=groups,
577
614
  input_sources=input_sources,
578
615
  nesting_order=nesting_order,
616
+ env_preset=env_preset,
617
+ environments=environments,
579
618
  element_sets=element_sets,
580
619
  sourceable_elem_iters=sourceable_elem_iters,
581
620
  )
582
621
  self._output_labels = output_labels or []
622
+ self.merge_envs = merge_envs
583
623
 
584
624
  # appended to when new element sets are added and reset on dump to disk:
585
625
  self._pending_element_sets = []
@@ -591,8 +631,73 @@ class Task(JSONLike):
591
631
  self._insert_ID = None
592
632
  self._dir_name = None
593
633
 
634
+ if self.merge_envs:
635
+ self._merge_envs_into_resources()
636
+
637
+ # TODO: consider adding a new element_set; will need to merge new environments?
638
+
594
639
  self._set_parent_refs({"schema": "schemas"})
595
640
 
641
+ def _merge_envs_into_resources(self):
642
+ # for each element set, merge `env_preset` into `resources` (this mutates
643
+ # `resources`, and should only happen on creation of the task, not
644
+ # re-initialisation from a persistent workflow):
645
+ self.merge_envs = False
646
+
647
+ # TODO: required so we don't raise below; can be removed once we consider multiple
648
+ # schemas:
649
+ has_presets = False
650
+ for es in self.element_sets:
651
+ if es.env_preset:
652
+ has_presets = True
653
+ break
654
+ for seq in es.sequences:
655
+ if seq.path == "env_preset":
656
+ has_presets = True
657
+ break
658
+ if has_presets:
659
+ break
660
+
661
+ if not has_presets:
662
+ return
663
+ try:
664
+ env_presets = self.schema.environment_presets
665
+ except ValueError:
666
+ # TODO: consider multiple schemas
667
+ raise NotImplementedError(
668
+ "Cannot merge environment presets into a task with multiple schemas."
669
+ )
670
+
671
+ for es in self.element_sets:
672
+ if es.env_preset:
673
+ # retrieve env specifiers from presets defined in the schema:
674
+ try:
675
+ env_specs = env_presets[es.env_preset]
676
+ except (TypeError, KeyError):
677
+ raise UnknownEnvironmentPresetError(
678
+ f"There is no environment preset named {es.env_preset!r} "
679
+ f"defined in the task schema {self.schema.name}."
680
+ )
681
+ envs_res = self.app.ResourceList(
682
+ [self.app.ResourceSpec(scope="any", environments=env_specs)]
683
+ )
684
+ es.resources.merge_other(envs_res)
685
+
686
+ for seq in es.sequences:
687
+ if seq.path == "env_preset":
688
+ # change to a resources path:
689
+ seq.path = f"resources.any.environments"
690
+ _values = []
691
+ for i in seq.values:
692
+ try:
693
+ _values.append(env_presets[i])
694
+ except (TypeError, KeyError):
695
+ raise UnknownEnvironmentPresetError(
696
+ f"There is no environment preset named {i!r} defined "
697
+ f"in the task schema {self.schema.name}."
698
+ )
699
+ seq._values = _values
700
+
596
701
  def _reset_pending_element_sets(self):
597
702
  self._pending_element_sets = []
598
703
 
@@ -1839,8 +1944,8 @@ class WorkflowTask:
1839
1944
  # run-specific data index to `test_rules` and `generate_data_index`
1840
1945
  # (e.g. if we wanted to increase the memory requirements of a action because
1841
1946
  # it previously failed)
1842
- rules_valid = action.test_rules(element_iter=element_iter)
1843
- if all(rules_valid):
1947
+ act_valid, cmds_idx = action.test_rules(element_iter=element_iter)
1948
+ if act_valid:
1844
1949
  self.app.logger.info(f"All action rules evaluated to true {log_common}")
1845
1950
  EAR_ID = self.workflow.num_EARs + count
1846
1951
  param_source = {
@@ -1864,6 +1969,7 @@ class WorkflowTask:
1864
1969
  run_0 = {
1865
1970
  "elem_iter_ID": element_iter.id_,
1866
1971
  "action_idx": act_idx,
1972
+ "commands_idx": cmds_idx,
1867
1973
  "metadata": {},
1868
1974
  }
1869
1975
  action_runs[(act_idx, EAR_ID)] = run_0
@@ -1877,6 +1983,7 @@ class WorkflowTask:
1877
1983
  self.workflow._store.add_EAR(
1878
1984
  elem_iter_ID=element_iter.id_,
1879
1985
  action_idx=act_idx,
1986
+ commands_idx=run["commands_idx"],
1880
1987
  data_idx=all_data_idx[(act_idx, EAR_ID_i)],
1881
1988
  metadata={},
1882
1989
  )
@@ -2,7 +2,7 @@ from contextlib import contextmanager
2
2
  import copy
3
3
  from dataclasses import dataclass
4
4
  from importlib import import_module
5
- from typing import Dict, List, Optional, Tuple, Union
5
+ from typing import Any, Dict, List, Optional, Tuple, Union
6
6
  from html import escape
7
7
 
8
8
  from rich import print as rich_print
@@ -13,6 +13,7 @@ from rich.markup import escape as rich_esc
13
13
  from rich.text import Text
14
14
 
15
15
  from hpcflow.sdk import app
16
+ from hpcflow.sdk.core.errors import EnvironmentPresetUnknownEnvironmentError
16
17
  from hpcflow.sdk.core.parameters import Parameter
17
18
  from .json_like import ChildObjectSpec, JSONLike
18
19
  from .parameters import NullDefault, ParameterPropagationMode, SchemaInput
@@ -89,6 +90,7 @@ class TaskSchema(JSONLike):
89
90
  version: Optional[str] = None,
90
91
  parameter_class_modules: Optional[List[str]] = None,
91
92
  web_doc: Optional[bool] = True,
93
+ environment_presets: Optional[Dict[str, Dict[str, Dict[str, Any]]]] = None,
92
94
  _hash_value: Optional[str] = None,
93
95
  ):
94
96
  self.objective = objective
@@ -99,6 +101,7 @@ class TaskSchema(JSONLike):
99
101
  self.outputs = outputs or []
100
102
  self.parameter_class_modules = parameter_class_modules or []
101
103
  self.web_doc = web_doc
104
+ self.environment_presets = environment_presets
102
105
  self._hash_value = _hash_value
103
106
 
104
107
  self._set_parent_refs()
@@ -114,6 +117,18 @@ class TaskSchema(JSONLike):
114
117
 
115
118
  self._update_parameter_value_classes()
116
119
 
120
+ if self.environment_presets:
121
+ # validate against env names in actions:
122
+ env_names = {act.get_environment_name() for act in self.actions}
123
+ preset_envs = {i for v in self.environment_presets.values() for i in v.keys()}
124
+ bad_envs = preset_envs - env_names
125
+ if bad_envs:
126
+ raise EnvironmentPresetUnknownEnvironmentError(
127
+ f"Task schema {self.name} has environment presets that refer to one "
128
+ f"or more environments that are not referenced in any of the task "
129
+ f"schema's actions: {', '.join(f'{i!r}' for i in bad_envs)}."
130
+ )
131
+
117
132
  # if version is not None: # TODO: this seems fragile
118
133
  # self.assign_versions(
119
134
  # version=version,
@@ -513,7 +528,7 @@ class TaskSchema(JSONLike):
513
528
  f'</tr><tr><td class="action-header-cell">scope:</td>'
514
529
  f"<td><code>{act.get_precise_scope().to_string()}</code></td></tr>"
515
530
  f'<tr><td class="action-header-cell">environment:</td>'
516
- f"<td><code>{act.get_environment().name}</code></td></tr>"
531
+ f"<td><code>{act.get_environment_name()}</code></td></tr>"
517
532
  f"{inp_fg_rows}"
518
533
  f"{out_fp_rows}"
519
534
  f"{act_i_script_rows}"
@@ -61,8 +61,11 @@ def make_parameters(num):
61
61
  return [hf.Parameter(f"p{i + 1}") for i in range(num)]
62
62
 
63
63
 
64
- def make_actions(ins_outs: List[Tuple[Union[Tuple, str], str]]) -> List[hf.Action]:
65
- act_env = hf.ActionEnvironment(environment="env1")
64
+ def make_actions(
65
+ ins_outs: List[Tuple[Union[Tuple, str], str]],
66
+ env="env1",
67
+ ) -> List[hf.Action]:
68
+ act_env = hf.ActionEnvironment(environment=env)
66
69
  actions = []
67
70
  for ins_outs_i in ins_outs:
68
71
  if len(ins_outs_i) == 2:
@@ -105,6 +105,7 @@ class WorkflowTemplate(JSONLike):
105
105
  """
106
106
 
107
107
  _app_attr = "app"
108
+ _validation_schema = "workflow_spec_schema.yaml"
108
109
 
109
110
  _child_objects = (
110
111
  ChildObjectSpec(
@@ -132,9 +133,12 @@ class WorkflowTemplate(JSONLike):
132
133
  loops: Optional[List[app.Loop]] = field(default_factory=lambda: [])
133
134
  workflow: Optional[app.Workflow] = None
134
135
  resources: Optional[Dict[str, Dict]] = None
136
+ environments: Optional[Dict[str, Dict[str, Any]]] = None
137
+ env_presets: Optional[Union[str, List[str]]] = None
135
138
  source_file: Optional[str] = field(default=None, compare=False)
136
139
  store_kwargs: Optional[Dict] = field(default_factory=lambda: {})
137
140
  merge_resources: Optional[bool] = True
141
+ merge_envs: Optional[bool] = True
138
142
 
139
143
  def __post_init__(self):
140
144
  self.resources = self.app.ResourceList.normalise(self.resources)
@@ -146,12 +150,95 @@ class WorkflowTemplate(JSONLike):
146
150
  if self.merge_resources:
147
151
  for task in self.tasks:
148
152
  for element_set in task.element_sets:
149
- element_set.resources.merge_template_resources(self.resources)
153
+ element_set.resources.merge_other(self.resources)
150
154
  self.merge_resources = False
151
155
 
156
+ if self.merge_envs:
157
+ self._merge_envs_into_task_resources()
158
+
152
159
  if self.doc and not isinstance(self.doc, list):
153
160
  self.doc = [self.doc]
154
161
 
162
+ def _merge_envs_into_task_resources(self):
163
+
164
+ self.merge_envs = False
165
+
166
+ # disallow both `env_presets` and `environments` specifications:
167
+ if self.env_presets and self.environments:
168
+ raise ValueError(
169
+ "Workflow template: specify at most one of `env_presets` and "
170
+ "`environments`."
171
+ )
172
+
173
+ if not isinstance(self.env_presets, list):
174
+ self.env_presets = [self.env_presets] if self.env_presets else []
175
+
176
+ for task in self.tasks:
177
+
178
+ # get applicable environments and environment preset names:
179
+ try:
180
+ schema = task.schema
181
+ except ValueError:
182
+ # TODO: consider multiple schemas
183
+ raise NotImplementedError(
184
+ "Cannot merge environment presets into a task without multiple "
185
+ "schemas."
186
+ )
187
+ schema_presets = schema.environment_presets
188
+ app_envs = {act.get_environment_name() for act in schema.actions}
189
+ for es in task.element_sets:
190
+ app_env_specs_i = None
191
+ if not es.environments and not es.env_preset:
192
+ # no task level envs/presets specified, so merge template-level:
193
+ if self.environments:
194
+ app_env_specs_i = {
195
+ k: v for k, v in self.environments.items() if k in app_envs
196
+ }
197
+ if app_env_specs_i:
198
+ self.app.logger.info(
199
+ f"(task {task.name!r}, element set {es.index}): using "
200
+ f"template-level requested `environment` specifiers: "
201
+ f"{app_env_specs_i!r}."
202
+ )
203
+ es.environments = app_env_specs_i
204
+
205
+ elif self.env_presets:
206
+ # take only the first applicable preset:
207
+ app_presets_i = [
208
+ k for k in self.env_presets if k in schema_presets
209
+ ]
210
+ if app_presets_i:
211
+ app_env_specs_i = schema_presets[app_presets_i[0]]
212
+ self.app.logger.info(
213
+ f"(task {task.name!r}, element set {es.index}): using "
214
+ f"template-level requested {app_presets_i[0]!r} "
215
+ f"`env_preset`: {app_env_specs_i!r}."
216
+ )
217
+ es.env_preset = app_presets_i[0]
218
+
219
+ else:
220
+ # no env/preset applicable here (and no env/preset at task level),
221
+ # so apply a default preset if available:
222
+ app_env_specs_i = (schema_presets or {}).get("", None)
223
+ if app_env_specs_i:
224
+ self.app.logger.info(
225
+ f"(task {task.name!r}, element set {es.index}): setting "
226
+ f"to default (empty-string named) `env_preset`: "
227
+ f"{app_env_specs_i}."
228
+ )
229
+ es.env_preset = ""
230
+
231
+ if app_env_specs_i:
232
+ es.resources.merge_other(
233
+ self.app.ResourceList(
234
+ [
235
+ self.app.ResourceSpec(
236
+ scope="any", environments=app_env_specs_i
237
+ )
238
+ ]
239
+ )
240
+ )
241
+
155
242
  @classmethod
156
243
  @TimeIt.decorator
157
244
  def _from_data(cls, data: Dict) -> app.WorkflowTemplate:
@@ -172,28 +259,29 @@ class WorkflowTemplate(JSONLike):
172
259
  }
173
260
 
174
261
  # extract out any template components:
175
- params_dat = data.pop("parameters", [])
262
+ tcs = data.pop("template_components", {})
263
+ params_dat = tcs.pop("parameters", [])
176
264
  if params_dat:
177
265
  parameters = cls.app.ParametersList.from_json_like(
178
266
  params_dat, shared_data=cls.app.template_components
179
267
  )
180
268
  cls.app.parameters.add_objects(parameters, skip_duplicates=True)
181
269
 
182
- cmd_files_dat = data.pop("command_files", [])
270
+ cmd_files_dat = tcs.pop("command_files", [])
183
271
  if cmd_files_dat:
184
272
  cmd_files = cls.app.CommandFilesList.from_json_like(
185
273
  cmd_files_dat, shared_data=cls.app.template_components
186
274
  )
187
275
  cls.app.command_files.add_objects(cmd_files, skip_duplicates=True)
188
276
 
189
- envs_dat = data.pop("environments", [])
277
+ envs_dat = tcs.pop("environments", [])
190
278
  if envs_dat:
191
279
  envs = cls.app.EnvironmentsList.from_json_like(
192
280
  envs_dat, shared_data=cls.app.template_components
193
281
  )
194
282
  cls.app.envs.add_objects(envs, skip_duplicates=True)
195
283
 
196
- ts_dat = data.pop("task_schemas", [])
284
+ ts_dat = tcs.pop("task_schemas", [])
197
285
  if ts_dat:
198
286
  task_schemas = cls.app.TaskSchemasList.from_json_like(
199
287
  ts_dat, shared_data=cls.app.template_components
@@ -1,64 +1,20 @@
1
1
  rules:
2
2
  - path: []
3
- condition:
4
- value.allowed_keys: [tasks]
5
-
6
- - path: [tasks]
7
- condition: { value.type.equal_to: list }
8
-
9
- - path: [tasks, { type: list_value }]
10
3
  condition:
11
4
  value.allowed_keys:
12
- [
13
- objective,
14
- method,
15
- implementation,
16
- resources,
17
- inputs,
18
- input_sources,
19
- input_files,
20
- perturbations,
21
- sequences,
22
- groups,
23
- repeats,
24
- nesting_order,
25
- ]
5
+ - doc
6
+ - name
7
+ - source_file
8
+ - resources
9
+ - environments
10
+ - env_presets
11
+ - template_components
12
+ - tasks
13
+ - loops
14
+ - store_kwargs
15
+ - merge_resources
16
+ - merge_envs
17
+ - workflow
26
18
 
27
- - path: [tasks, { type: list_value }, resources]
28
- condition: { value.type.equal_to: dict }
29
-
30
- - path: [tasks, { type: list_value }, perturbations]
31
- condition: { value.type.equal_to: dict }
32
-
33
- - path: [tasks, { type: list_value }, nesting_order]
34
- condition: { value.type.equal_to: dict }
35
-
36
- - path: [tasks, { type: list_value }, sequences]
37
- condition: { value.type.equal_to: list }
38
-
39
- - path: [tasks, { type: list_value }, inputs]
40
- condition: { value.type.in: [list, dict] }
41
-
42
- - path: [tasks, { type: list_value }, inputs, { type: list_value }]
43
- condition:
44
- and:
45
- - value.required_keys: [parameter, value]
46
- - value.allowed_keys: [parameter, value, path]
47
-
48
- - path: [tasks, { type: list_value }, inputs, { type: list_value }, parameter]
49
- condition: { value.type.equal_to: str }
50
-
51
- - path: [tasks, { type: list_value }, inputs, { type: list_value }, path]
19
+ - path: [tasks]
52
20
  condition: { value.type.equal_to: list }
53
-
54
- - path: [tasks, { type: list_value }, sequences, { type: list_value }]
55
- condition:
56
- and:
57
- - value.required_keys: [path, nesting_order]
58
- - value.keys_contain_one_of:
59
- [
60
- values,
61
- values.from_linear_space,
62
- values.from_grometric_space,
63
- values_from_log_space,
64
- ]