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
hpcflow/_version.py CHANGED
@@ -1 +1 @@
1
- __version__ = "0.2.0a162"
1
+ __version__ = "0.2.0a164"
@@ -0,0 +1,7 @@
1
+ def main_script_test_direct_in_direct_out_env_spec(p1, env_spec):
2
+
3
+ # process
4
+ p2 = p1 + 100
5
+
6
+ # return outputs
7
+ return {"p2": env_spec}
hpcflow/sdk/app.py CHANGED
@@ -729,49 +729,28 @@ class BaseApp(metaclass=Singleton):
729
729
 
730
730
  @TimeIt.decorator
731
731
  def _load_scripts(self):
732
- from setuptools import find_packages
733
732
 
734
733
  # TODO: load custom directories / custom functions (via decorator)
734
+ scripts_package = f"{self.package_name}.{self.scripts_dir}"
735
735
 
736
- app_module = import_module(self.package_name)
737
- root_scripts_dir = self.scripts_dir
738
-
739
- packages = find_packages(
740
- where=str(Path(app_module.__path__[0], *root_scripts_dir.split(".")))
741
- )
742
- packages = [root_scripts_dir] + [root_scripts_dir + "." + i for i in packages]
743
- packages = [self.package_name + "." + i for i in packages]
744
- num_root_dirs = len(root_scripts_dir.split(".")) + 1
736
+ try:
737
+ ctx = resources.as_file(resources.files(scripts_package))
738
+ except AttributeError:
739
+ # < python 3.9; `resource.path` deprecated since 3.11
740
+ ctx = resources.path(scripts_package, "")
745
741
 
746
742
  scripts = {}
747
- for pkg in packages:
748
- try:
749
- contents = (
750
- resource.name
751
- for resource in resources.files(pkg).iterdir()
752
- if resource.is_file()
753
- )
754
- _is_rsrc = lambda pkg, name: resources.files(pkg).joinpath(name).is_file()
755
-
756
- except AttributeError:
757
- # < python 3.9; `resource.contents` deprecated since 3.11
758
- contents = resources.contents(pkg)
759
- _is_rsrc = lambda pkg, name: resources.is_resource(pkg, name)
760
-
761
- script_names = (
762
- name for name in contents if name != "__init__.py" and _is_rsrc(pkg, name)
763
- )
764
-
765
- for i in script_names:
766
- script_key = "/".join(pkg.split(".")[num_root_dirs:] + [i])
767
- try:
768
- script_ctx = resources.as_file(resources.files(pkg).joinpath(i))
769
- except AttributeError:
770
- # < python 3.9; `resource.path` deprecated since 3.11
771
- script_ctx = resources.path(pkg, i)
772
-
773
- with script_ctx as script:
774
- scripts[script_key] = script
743
+ with ctx as path:
744
+ for dirpath, _, filenames in os.walk(path):
745
+ dirpath = Path(dirpath)
746
+ if dirpath.name == "__pycache__":
747
+ continue
748
+ for filename in filenames:
749
+ if filename == "__init__.py":
750
+ continue
751
+ val = dirpath.joinpath(filename)
752
+ key = str(val.relative_to(path).as_posix())
753
+ scripts[key] = Path(val)
775
754
 
776
755
  return scripts
777
756
 
@@ -1380,15 +1359,19 @@ class BaseApp(metaclass=Singleton):
1380
1359
  variables=variables,
1381
1360
  status=status,
1382
1361
  )
1383
- return wk.submit(
1362
+ submitted_js = wk.submit(
1384
1363
  JS_parallelism=JS_parallelism,
1385
1364
  wait=wait,
1386
1365
  add_to_known=add_to_known,
1387
- return_idx=return_idx,
1366
+ return_idx=True,
1388
1367
  tasks=tasks,
1389
1368
  cancel=cancel,
1390
1369
  status=status,
1391
1370
  )
1371
+ if return_idx:
1372
+ return (wk, submitted_js)
1373
+ else:
1374
+ return wk
1392
1375
 
1393
1376
  def _make_demo_workflow(
1394
1377
  self,
@@ -1553,15 +1536,19 @@ class BaseApp(metaclass=Singleton):
1553
1536
  store_kwargs=store_kwargs,
1554
1537
  variables=variables,
1555
1538
  )
1556
- return wk.submit(
1539
+ submitted_js = wk.submit(
1557
1540
  JS_parallelism=JS_parallelism,
1558
1541
  wait=wait,
1559
1542
  add_to_known=add_to_known,
1560
- return_idx=return_idx,
1543
+ return_idx=True,
1561
1544
  tasks=tasks,
1562
1545
  cancel=cancel,
1563
1546
  status=status,
1564
1547
  )
1548
+ if return_idx:
1549
+ return (wk, submitted_js)
1550
+ else:
1551
+ return wk
1565
1552
 
1566
1553
  def _submit_workflow(
1567
1554
  self,
hpcflow/sdk/cli.py CHANGED
@@ -173,7 +173,7 @@ def _make_API_CLI(app):
173
173
  status=status,
174
174
  )
175
175
  if print_idx:
176
- click.echo(out)
176
+ click.echo(out[1])
177
177
 
178
178
  @click.command(context_settings={"ignore_unknown_options": True})
179
179
  @click.argument("py_test_args", nargs=-1, type=click.UNPROCESSED)
@@ -16,6 +16,7 @@ from watchdog.utils.dirsnapshot import DirectorySnapshotDiff
16
16
  from hpcflow.sdk import app
17
17
  from hpcflow.sdk.core import ABORT_EXIT_CODE
18
18
  from hpcflow.sdk.core.errors import (
19
+ ActionEnvironmentMissingNameError,
19
20
  MissingCompatibleActionEnvironment,
20
21
  OutputFileParserNoOutputError,
21
22
  UnknownScriptDataKey,
@@ -140,6 +141,7 @@ class ElementActionRun:
140
141
  element_action,
141
142
  index: int,
142
143
  data_idx: Dict,
144
+ commands_idx: List[int],
143
145
  start_time: Union[datetime, None],
144
146
  end_time: Union[datetime, None],
145
147
  snapshot_start: Union[Dict, None],
@@ -156,6 +158,7 @@ class ElementActionRun:
156
158
  self._element_action = element_action
157
159
  self._index = index # local index of this run with the action
158
160
  self._data_idx = data_idx
161
+ self._commands_idx = commands_idx
159
162
  self._start_time = start_time
160
163
  self._end_time = end_time
161
164
  self._submission_idx = submission_idx
@@ -221,6 +224,10 @@ class ElementActionRun:
221
224
  def data_idx(self):
222
225
  return self._data_idx
223
226
 
227
+ @property
228
+ def commands_idx(self):
229
+ return self._commands_idx
230
+
224
231
  @property
225
232
  def metadata(self):
226
233
  return self._metadata
@@ -458,14 +465,18 @@ class ElementActionRun:
458
465
  self._output_files = self.app.ElementOutputFiles(element_action_run=self)
459
466
  return self._output_files
460
467
 
468
+ @property
469
+ def env_spec(self) -> Dict[str, Any]:
470
+ return self.resources.environments[self.action.get_environment_name()]
471
+
461
472
  @TimeIt.decorator
462
473
  def get_resources(self):
463
474
  """Resolve specific resources for this EAR, considering all applicable scopes and
464
475
  template-level resources."""
465
476
  return self.element_iteration.get_resources(self.action)
466
477
 
467
- def get_environment_label(self) -> str:
468
- return self.action.get_environment_label()
478
+ def get_environment_spec(self) -> str:
479
+ return self.action.get_environment_spec()
469
480
 
470
481
  def get_environment(self) -> app.Environment:
471
482
  return self.action.get_environment()
@@ -542,6 +553,9 @@ class ElementActionRun:
542
553
  else:
543
554
  out[key] = val_i
544
555
 
556
+ if self.action.script_pass_env_spec:
557
+ out["env_spec"] = self.env_spec
558
+
545
559
  return out
546
560
 
547
561
  def get_input_values_direct(self, label_dict: bool = True):
@@ -562,6 +576,10 @@ class ElementActionRun:
562
576
  typ = i.path[len("inputs.") :]
563
577
  if typ in input_types:
564
578
  inputs[typ] = i.value
579
+
580
+ if self.action.script_pass_env_spec:
581
+ inputs["env_spec"] = self.env_spec
582
+
565
583
  return inputs
566
584
 
567
585
  def get_OFP_output_files(self) -> Dict[str, Union[str, List[str]]]:
@@ -585,6 +603,10 @@ class ElementActionRun:
585
603
  inputs = {}
586
604
  for inp_typ in self.action.output_file_parsers[0].inputs or []:
587
605
  inputs[inp_typ] = self.get(f"inputs.{inp_typ}")
606
+
607
+ if self.action.script_pass_env_spec:
608
+ inputs["env_spec"] = self.env_spec
609
+
588
610
  return inputs
589
611
 
590
612
  def get_OFP_outputs(self) -> Dict[str, Union[str, List[str]]]:
@@ -626,7 +648,7 @@ class ElementActionRun:
626
648
 
627
649
  # write the script if it is specified as a app data script, otherwise we assume
628
650
  # the script already exists in the working directory:
629
- snip_path = self.action.get_snippet_script_path(self.action.script)
651
+ snip_path = self.action.get_snippet_script_path(self.action.script, self.env_spec)
630
652
  if snip_path:
631
653
  script_name = snip_path.name
632
654
  source_str = self.action.compose_source(snip_path)
@@ -677,32 +699,35 @@ class ElementActionRun:
677
699
  "stdout"/"stderr").
678
700
  """
679
701
  self.app.persistence_logger.debug("EAR.compose_commands")
702
+ env_spec = self.env_spec
703
+
680
704
  for ifg in self.action.input_file_generators:
681
705
  # TODO: there should only be one at this stage if expanded?
682
- ifg.write_source(self.action)
706
+ ifg.write_source(self.action, env_spec)
683
707
 
684
708
  for ofp in self.action.output_file_parsers:
685
709
  # TODO: there should only be one at this stage if expanded?
686
710
  if ofp.output is None:
687
711
  raise OutputFileParserNoOutputError()
688
- ofp.write_source(self.action)
712
+ ofp.write_source(self.action, env_spec)
689
713
 
690
714
  if self.action.script:
691
715
  self.write_source(js_idx=jobscript.index, js_act_idx=JS_action_idx)
692
716
 
693
717
  command_lns = []
694
- env_label = self.action.get_environment_label()
695
- env = jobscript.submission.environments.get(env_label)
718
+ env = jobscript.submission.environments.get(**env_spec)
696
719
  if env.setup:
697
720
  command_lns += list(env.setup)
698
721
 
699
722
  shell_vars = {} # keys are cmd_idx, each value is a list of tuples
700
723
  for cmd_idx, command in enumerate(self.action.commands):
701
- cmd_str, shell_vars_i = command.get_command_line(
702
- EAR=self, shell=jobscript.shell, env=env
703
- )
704
- shell_vars[cmd_idx] = shell_vars_i
705
- command_lns.append(cmd_str)
724
+ if cmd_idx in self.commands_idx:
725
+ # only execute commands that have no rules, or all valid rules:
726
+ cmd_str, shell_vars_i = command.get_command_line(
727
+ EAR=self, shell=jobscript.shell, env=env
728
+ )
729
+ shell_vars[cmd_idx] = shell_vars_i
730
+ command_lns.append(cmd_str)
706
731
 
707
732
  commands = "\n".join(command_lns) + "\n"
708
733
 
@@ -953,13 +978,24 @@ class ActionEnvironment(JSONLike):
953
978
  ),
954
979
  )
955
980
 
956
- environment: str # app.Environment
981
+ environment: Union[str, Dict[str, Any]]
957
982
  scope: Optional[app.ActionScope] = None
958
983
 
959
984
  def __post_init__(self):
960
985
  if self.scope is None:
961
986
  self.scope = self.app.ActionScope.any()
962
987
 
988
+ orig_env = copy.deepcopy(self.environment)
989
+ if isinstance(self.environment, str):
990
+ self.environment = {"name": self.environment}
991
+
992
+ if "name" not in self.environment:
993
+ raise ActionEnvironmentMissingNameError(
994
+ f"The action-environment environment specification must include a string "
995
+ f"`name` key, or be specified as string that is that name. Provided "
996
+ f"environment key was {orig_env!r}."
997
+ )
998
+
963
999
 
964
1000
  class ActionRule(JSONLike):
965
1001
  """Class to represent a rule/condition that must be True if an action is to be
@@ -997,6 +1033,7 @@ class ActionRule(JSONLike):
997
1033
 
998
1034
  self.rule = rule
999
1035
  self.action = None # assigned by parent action
1036
+ self.command = None # assigned by parent command
1000
1037
 
1001
1038
  def __eq__(self, other):
1002
1039
  if type(other) is not self.__class__:
@@ -1089,6 +1126,7 @@ class Action(JSONLike):
1089
1126
  script_data_out: Optional[str] = None,
1090
1127
  script_data_files_use_opt: Optional[bool] = False,
1091
1128
  script_exe: Optional[str] = None,
1129
+ script_pass_env_spec: Optional[bool] = False,
1092
1130
  abortable: Optional[bool] = False,
1093
1131
  input_file_generators: Optional[List[app.InputFileGenerator]] = None,
1094
1132
  output_file_parsers: Optional[List[app.OutputFileParser]] = None,
@@ -1117,6 +1155,7 @@ class Action(JSONLike):
1117
1155
  script_data_files_use_opt if not self.script_is_python else True
1118
1156
  )
1119
1157
  self.script_exe = script_exe.lower() if script_exe else None
1158
+ self.script_pass_env_spec = script_pass_env_spec
1120
1159
  self.environments = environments or [
1121
1160
  self.app.ActionEnvironment(environment="null_env")
1122
1161
  ]
@@ -1413,7 +1452,10 @@ class Action(JSONLike):
1413
1452
  commands=self.commands,
1414
1453
  )
1415
1454
 
1416
- def get_environment_label(self) -> str:
1455
+ def get_environment_name(self) -> str:
1456
+ return self.get_environment_spec()["name"]
1457
+
1458
+ def get_environment_spec(self) -> Dict[str, Any]:
1417
1459
  if not self._from_expand:
1418
1460
  raise RuntimeError(
1419
1461
  f"Cannot choose a single environment from this action because it is not "
@@ -1422,7 +1464,7 @@ class Action(JSONLike):
1422
1464
  return self.environments[0].environment
1423
1465
 
1424
1466
  def get_environment(self) -> app.Environment:
1425
- return self.app.envs.get(self.get_environment_label())
1467
+ return self.app.envs.get(**self.get_environment_spec())
1426
1468
 
1427
1469
  @staticmethod
1428
1470
  def is_snippet_script(script: str) -> bool:
@@ -1442,7 +1484,9 @@ class Action(JSONLike):
1442
1484
  return script
1443
1485
 
1444
1486
  @classmethod
1445
- def get_snippet_script_str(cls, script) -> str:
1487
+ def get_snippet_script_str(
1488
+ cls, script, env_spec: Optional[Dict[str, Any]] = None
1489
+ ) -> str:
1446
1490
  if not cls.is_snippet_script(script):
1447
1491
  raise ValueError(
1448
1492
  f"Must be an app-data script name (e.g. "
@@ -1450,14 +1494,24 @@ class Action(JSONLike):
1450
1494
  )
1451
1495
  pattern = r"\<\<script:(.*:?)\>\>"
1452
1496
  match_obj = re.match(pattern, script)
1453
- return match_obj.group(1)
1497
+ out = match_obj.group(1)
1498
+
1499
+ if env_spec:
1500
+ out = re.sub(
1501
+ pattern=r"\<\<env:(.*?)\>\>",
1502
+ repl=lambda match_obj: env_spec[match_obj.group(1)],
1503
+ string=out,
1504
+ )
1505
+ return out
1454
1506
 
1455
1507
  @classmethod
1456
- def get_snippet_script_path(cls, script_path) -> Path:
1508
+ def get_snippet_script_path(
1509
+ cls, script_path, env_spec: Optional[Dict[str, Any]] = None
1510
+ ) -> Path:
1457
1511
  if not cls.is_snippet_script(script_path):
1458
1512
  return False
1459
1513
 
1460
- path = cls.get_snippet_script_str(script_path)
1514
+ path = cls.get_snippet_script_str(script_path, env_spec)
1461
1515
  if path in cls.app.scripts:
1462
1516
  path = cls.app.scripts.get(path)
1463
1517
 
@@ -1527,7 +1581,9 @@ class Action(JSONLike):
1527
1581
  input_file_generators=[ifg],
1528
1582
  environments=[self.get_input_file_generator_action_env(ifg)],
1529
1583
  rules=main_rules + ifg.get_action_rules(),
1584
+ script_pass_env_spec=ifg.script_pass_env_spec,
1530
1585
  abortable=ifg.abortable,
1586
+ # TODO: add script_data_in etc? and to OFP?
1531
1587
  )
1532
1588
  act_i._task_schema = self.task_schema
1533
1589
  if ifg.input_file not in inp_files:
@@ -1558,6 +1614,7 @@ class Action(JSONLike):
1558
1614
  output_file_parsers=[ofp],
1559
1615
  environments=[self.get_output_file_parser_action_env(ofp)],
1560
1616
  rules=list(self.rules) + ofp.get_action_rules(),
1617
+ script_pass_env_spec=ofp.script_pass_env_spec,
1561
1618
  abortable=ofp.abortable,
1562
1619
  )
1563
1620
  act_i._task_schema = self.task_schema
@@ -1616,6 +1673,7 @@ class Action(JSONLike):
1616
1673
  script_data_in=self.script_data_in,
1617
1674
  script_data_out=self.script_data_out,
1618
1675
  script_exe=self.script_exe,
1676
+ script_pass_env_spec=self.script_pass_env_spec,
1619
1677
  environments=[self.get_commands_action_env()],
1620
1678
  abortable=self.abortable,
1621
1679
  rules=main_rules,
@@ -1909,9 +1967,17 @@ class Action(JSONLike):
1909
1967
  return True
1910
1968
 
1911
1969
  @TimeIt.decorator
1912
- def test_rules(self, element_iter) -> List[bool]:
1970
+ def test_rules(self, element_iter) -> Tuple[bool, List[int]]:
1913
1971
  """Test all rules against the specified element iteration."""
1914
- return [i.test(element_iteration=element_iter) for i in self.rules]
1972
+ rules_valid = [rule.test(element_iteration=element_iter) for rule in self.rules]
1973
+ action_valid = all(rules_valid)
1974
+ commands_idx = []
1975
+ if action_valid:
1976
+ for cmd_idx, cmd in enumerate(self.commands):
1977
+ if any(not i.test(element_iteration=element_iter) for i in cmd.rules):
1978
+ continue
1979
+ commands_idx.append(cmd_idx)
1980
+ return action_valid, commands_idx
1915
1981
 
1916
1982
  def get_required_executables(self) -> Tuple[str]:
1917
1983
  """Return executable labels required by this action."""
@@ -131,6 +131,7 @@ class InputFileGenerator(JSONLike):
131
131
  inputs: List[app.Parameter]
132
132
  script: str = None
133
133
  environment: app.Environment = None
134
+ script_pass_env_spec: Optional[bool] = False
134
135
  abortable: Optional[bool] = False
135
136
  rules: Optional[List[app.ActionRule]] = None
136
137
 
@@ -188,11 +189,11 @@ class InputFileGenerator(JSONLike):
188
189
  out = out.format(script_str=script_str, main_block=main_block)
189
190
  return out
190
191
 
191
- def write_source(self, action):
192
+ def write_source(self, action, env_spec: Dict[str, Any]):
192
193
 
193
194
  # write the script if it is specified as a snippet script, otherwise we assume
194
195
  # the script already exists in the working directory:
195
- snip_path = action.get_snippet_script_path(self.script)
196
+ snip_path = action.get_snippet_script_path(self.script, env_spec)
196
197
  if snip_path:
197
198
  source_str = self.compose_source(snip_path)
198
199
  with Path(snip_path.name).open("wt", newline="\n") as fp:
@@ -255,6 +256,7 @@ class OutputFileParser(JSONLike):
255
256
  inputs: List[str] = None
256
257
  outputs: List[str] = None
257
258
  options: Dict = None
259
+ script_pass_env_spec: Optional[bool] = False
258
260
  abortable: Optional[bool] = False
259
261
  save_files: Union[List[str], bool] = True
260
262
  clean_up: Optional[List[str]] = None
@@ -342,14 +344,14 @@ class OutputFileParser(JSONLike):
342
344
  out = out.format(script_str=script_str, main_block=main_block)
343
345
  return out
344
346
 
345
- def write_source(self, action):
347
+ def write_source(self, action, env_spec: Dict[str, Any]):
346
348
  if self.output is None:
347
349
  # might be used just for saving files:
348
350
  return
349
351
 
350
352
  # write the script if it is specified as a snippet script, otherwise we assume
351
353
  # the script already exists in the working directory:
352
- snip_path = action.get_snippet_script_path(self.script)
354
+ snip_path = action.get_snippet_script_path(self.script, env_spec)
353
355
  if snip_path:
354
356
  source_str = self.compose_source(snip_path)
355
357
  with Path(snip_path.name).open("wt", newline="\n") as fp:
@@ -1,4 +1,4 @@
1
- from dataclasses import dataclass
1
+ from dataclasses import dataclass, field
2
2
  from functools import partial
3
3
  from pathlib import Path
4
4
  import re
@@ -6,15 +6,24 @@ from typing import Dict, Iterable, List, Optional, Tuple, Union
6
6
 
7
7
  import numpy as np
8
8
 
9
+ from hpcflow.sdk import app
9
10
  from hpcflow.sdk.core.element import ElementResources
10
11
  from hpcflow.sdk.core.errors import NoCLIFormatMethodError
11
- from hpcflow.sdk.core.json_like import JSONLike
12
+ from hpcflow.sdk.core.json_like import ChildObjectSpec, JSONLike
12
13
  from hpcflow.sdk.core.parameters import ParameterValue
13
14
 
14
15
 
15
16
  @dataclass
16
17
  class Command(JSONLike):
17
18
  _app_attr = "app"
19
+ _child_objects = (
20
+ ChildObjectSpec(
21
+ name="rules",
22
+ class_name="ActionRule",
23
+ is_multiple=True,
24
+ parent_ref="command",
25
+ ),
26
+ )
18
27
 
19
28
  command: Optional[str] = None
20
29
  executable: Optional[str] = None
@@ -23,6 +32,7 @@ class Command(JSONLike):
23
32
  stdout: Optional[str] = None
24
33
  stderr: Optional[str] = None
25
34
  stdin: Optional[str] = None
35
+ rules: Optional[List[app.ActionRule]] = field(default_factory=lambda: [])
26
36
 
27
37
  def __repr__(self) -> str:
28
38
  out = []
@@ -40,6 +50,8 @@ class Command(JSONLike):
40
50
  out.append(f"stderr={self.stderr!r}")
41
51
  if self.stdin:
42
52
  out.append(f"stdin={self.stdin!r}")
53
+ if self.rules:
54
+ out.append(f"rules={self.rules!r}")
43
55
 
44
56
  return f"{self.__class__.__name__}({', '.join(out)})"
45
57
 
@@ -107,6 +119,7 @@ class Command(JSONLike):
107
119
 
108
120
  file_regex = r"(\<\<file:{}\>\>?)"
109
121
  exe_script_regex = r"\<\<(executable|script):(.*?)\>\>"
122
+ env_specs_regex = r"\<\<env:(.*?)\>\>"
110
123
 
111
124
  # substitute executables:
112
125
  cmd_str = re.sub(
@@ -118,6 +131,12 @@ class Command(JSONLike):
118
131
  # executable command might itself contain variables defined in `variables`, and/or
119
132
  # an `<<args>>` variable::
120
133
  for var_key, var_val in (self.variables or {}).items():
134
+ # substitute any `<<env:>>` specifiers
135
+ var_val = re.sub(
136
+ pattern=env_specs_regex,
137
+ repl=lambda match_obj: EAR.env_spec[match_obj.group(1)],
138
+ string=var_val,
139
+ )
121
140
  cmd_str = cmd_str.replace(f"<<{var_key}>>", var_val)
122
141
  if "<<args>>" in cmd_str:
123
142
  args_str = " ".join(self.arguments or [])
@@ -199,6 +199,7 @@ class ElementResources(JSONLike):
199
199
  scheduler_args: Optional[Dict] = None
200
200
  shell_args: Optional[Dict] = None
201
201
  os_name: Optional[str] = None
202
+ environments: Optional[Dict] = None
202
203
 
203
204
  # SGE scheduler specific:
204
205
  SGE_parallel_env: str = None
@@ -241,16 +242,24 @@ class ElementResources(JSONLike):
241
242
  return hash(tuple((keys, vals)))
242
243
 
243
244
  exclude = ("time_limit",)
244
- sub_dicts = ("scheduler_args", "shell_args")
245
245
  dct = {k: copy.deepcopy(v) for k, v in self.__dict__.items() if k not in exclude}
246
- if "options" in dct.get("scheduler_args", []):
247
- dct["scheduler_args"]["options"] = _hash_dict(
248
- dct["scheduler_args"]["options"]
249
- )
250
246
 
251
- for k in sub_dicts:
252
- if k in dct:
253
- dct[k] = _hash_dict(dct[k])
247
+ scheduler_args = dct["scheduler_args"]
248
+ shell_args = dct["shell_args"]
249
+ envs = dct["environments"]
250
+
251
+ if isinstance(scheduler_args, dict):
252
+ if "options" in scheduler_args:
253
+ dct["scheduler_args"]["options"] = _hash_dict(scheduler_args["options"])
254
+ dct["scheduler_args"] = _hash_dict(dct["scheduler_args"])
255
+
256
+ if isinstance(shell_args, dict):
257
+ dct["shell_args"] = _hash_dict(shell_args)
258
+
259
+ if isinstance(envs, dict):
260
+ for k, v in envs.items():
261
+ dct["environments"][k] = _hash_dict(v)
262
+ dct["environments"] = _hash_dict(dct["environments"])
254
263
 
255
264
  return _hash_dict(dct)
256
265
 
@@ -878,6 +887,28 @@ class ElementIteration:
878
887
  # an EAR?" which would then allow us to test a resources-based action rule.
879
888
 
880
889
  resource_specs = copy.deepcopy(self.get("resources"))
890
+
891
+ env_spec = action.get_environment_spec()
892
+ env_name = env_spec["name"]
893
+
894
+ # set default env specifiers, if none set:
895
+ if "any" not in resource_specs:
896
+ resource_specs["any"] = {}
897
+ if "environments" not in resource_specs["any"]:
898
+ resource_specs["any"]["environments"] = {env_name: copy.deepcopy(env_spec)}
899
+
900
+ for scope, dat in resource_specs.items():
901
+ if "environments" in dat:
902
+ # keep only relevant user-provided environment specifiers:
903
+ resource_specs[scope]["environments"] = {
904
+ k: v for k, v in dat["environments"].items() if k == env_name
905
+ }
906
+ # merge user-provided specifiers into action specifiers:
907
+ resource_specs[scope]["environments"][env_name] = {
908
+ **resource_specs[scope]["environments"].get(env_name, {}),
909
+ **copy.deepcopy(env_spec),
910
+ }
911
+
881
912
  resources = {}
882
913
  for scope in action.get_possible_scopes()[::-1]:
883
914
  # loop in reverse so higher-specificity scopes take precedence:
@@ -95,6 +95,10 @@ class MissingActionEnvironment(Exception):
95
95
  pass
96
96
 
97
97
 
98
+ class ActionEnvironmentMissingNameError(Exception):
99
+ pass
100
+
101
+
98
102
  class FromSpecMissingObjectError(Exception):
99
103
  pass
100
104
 
@@ -391,3 +395,15 @@ class UnknownScriptDataKey(ValueError):
391
395
 
392
396
  class MissingVariableSubstitutionError(KeyError):
393
397
  pass
398
+
399
+
400
+ class EnvironmentPresetUnknownEnvironmentError(ValueError):
401
+ pass
402
+
403
+
404
+ class UnknownEnvironmentPresetError(ValueError):
405
+ pass
406
+
407
+
408
+ class MultipleEnvironmentsError(ValueError):
409
+ pass