hpcflow 0.1.15__py3-none-any.whl → 0.2.0a271__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 (275) hide show
  1. hpcflow/__init__.py +2 -11
  2. hpcflow/__pyinstaller/__init__.py +5 -0
  3. hpcflow/__pyinstaller/hook-hpcflow.py +40 -0
  4. hpcflow/_version.py +1 -1
  5. hpcflow/app.py +43 -0
  6. hpcflow/cli.py +2 -461
  7. hpcflow/data/demo_data_manifest/__init__.py +3 -0
  8. hpcflow/data/demo_data_manifest/demo_data_manifest.json +6 -0
  9. hpcflow/data/jinja_templates/test/test_template.txt +8 -0
  10. hpcflow/data/programs/hello_world/README.md +1 -0
  11. hpcflow/data/programs/hello_world/hello_world.c +87 -0
  12. hpcflow/data/programs/hello_world/linux/hello_world +0 -0
  13. hpcflow/data/programs/hello_world/macos/hello_world +0 -0
  14. hpcflow/data/programs/hello_world/win/hello_world.exe +0 -0
  15. hpcflow/data/scripts/__init__.py +1 -0
  16. hpcflow/data/scripts/bad_script.py +2 -0
  17. hpcflow/data/scripts/demo_task_1_generate_t1_infile_1.py +8 -0
  18. hpcflow/data/scripts/demo_task_1_generate_t1_infile_2.py +8 -0
  19. hpcflow/data/scripts/demo_task_1_parse_p3.py +7 -0
  20. hpcflow/data/scripts/do_nothing.py +2 -0
  21. hpcflow/data/scripts/env_specifier_test/input_file_generator_pass_env_spec.py +4 -0
  22. hpcflow/data/scripts/env_specifier_test/main_script_test_pass_env_spec.py +8 -0
  23. hpcflow/data/scripts/env_specifier_test/output_file_parser_pass_env_spec.py +4 -0
  24. hpcflow/data/scripts/env_specifier_test/v1/input_file_generator_basic.py +4 -0
  25. hpcflow/data/scripts/env_specifier_test/v1/main_script_test_direct_in_direct_out.py +7 -0
  26. hpcflow/data/scripts/env_specifier_test/v1/output_file_parser_basic.py +4 -0
  27. hpcflow/data/scripts/env_specifier_test/v2/main_script_test_direct_in_direct_out.py +7 -0
  28. hpcflow/data/scripts/generate_t1_file_01.py +7 -0
  29. hpcflow/data/scripts/import_future_script.py +7 -0
  30. hpcflow/data/scripts/input_file_generator_basic.py +3 -0
  31. hpcflow/data/scripts/input_file_generator_basic_FAIL.py +3 -0
  32. hpcflow/data/scripts/input_file_generator_test_stdout_stderr.py +8 -0
  33. hpcflow/data/scripts/main_script_test_direct_in.py +3 -0
  34. hpcflow/data/scripts/main_script_test_direct_in_direct_out.py +6 -0
  35. hpcflow/data/scripts/main_script_test_direct_in_direct_out_2.py +6 -0
  36. hpcflow/data/scripts/main_script_test_direct_in_direct_out_2_fail_allowed.py +6 -0
  37. hpcflow/data/scripts/main_script_test_direct_in_direct_out_2_fail_allowed_group.py +7 -0
  38. hpcflow/data/scripts/main_script_test_direct_in_direct_out_3.py +6 -0
  39. hpcflow/data/scripts/main_script_test_direct_in_direct_out_all_iters_test.py +15 -0
  40. hpcflow/data/scripts/main_script_test_direct_in_direct_out_env_spec.py +7 -0
  41. hpcflow/data/scripts/main_script_test_direct_in_direct_out_labels.py +8 -0
  42. hpcflow/data/scripts/main_script_test_direct_in_group_direct_out_3.py +6 -0
  43. hpcflow/data/scripts/main_script_test_direct_in_group_one_fail_direct_out_3.py +6 -0
  44. hpcflow/data/scripts/main_script_test_direct_sub_param_in_direct_out.py +6 -0
  45. hpcflow/data/scripts/main_script_test_hdf5_in_obj.py +12 -0
  46. hpcflow/data/scripts/main_script_test_hdf5_in_obj_2.py +12 -0
  47. hpcflow/data/scripts/main_script_test_hdf5_in_obj_group.py +12 -0
  48. hpcflow/data/scripts/main_script_test_hdf5_out_obj.py +11 -0
  49. hpcflow/data/scripts/main_script_test_json_and_direct_in_json_out.py +14 -0
  50. hpcflow/data/scripts/main_script_test_json_in_json_and_direct_out.py +17 -0
  51. hpcflow/data/scripts/main_script_test_json_in_json_out.py +14 -0
  52. hpcflow/data/scripts/main_script_test_json_in_json_out_labels.py +16 -0
  53. hpcflow/data/scripts/main_script_test_json_in_obj.py +12 -0
  54. hpcflow/data/scripts/main_script_test_json_out_FAIL.py +3 -0
  55. hpcflow/data/scripts/main_script_test_json_out_obj.py +10 -0
  56. hpcflow/data/scripts/main_script_test_json_sub_param_in_json_out_labels.py +16 -0
  57. hpcflow/data/scripts/main_script_test_shell_env_vars.py +12 -0
  58. hpcflow/data/scripts/main_script_test_std_out_std_err.py +6 -0
  59. hpcflow/data/scripts/output_file_parser_basic.py +3 -0
  60. hpcflow/data/scripts/output_file_parser_basic_FAIL.py +7 -0
  61. hpcflow/data/scripts/output_file_parser_test_stdout_stderr.py +8 -0
  62. hpcflow/data/scripts/parse_t1_file_01.py +4 -0
  63. hpcflow/data/scripts/script_exit_test.py +5 -0
  64. hpcflow/data/template_components/__init__.py +1 -0
  65. hpcflow/data/template_components/command_files.yaml +26 -0
  66. hpcflow/data/template_components/environments.yaml +13 -0
  67. hpcflow/data/template_components/parameters.yaml +14 -0
  68. hpcflow/data/template_components/task_schemas.yaml +139 -0
  69. hpcflow/data/workflows/workflow_1.yaml +5 -0
  70. hpcflow/examples.ipynb +1037 -0
  71. hpcflow/sdk/__init__.py +149 -0
  72. hpcflow/sdk/app.py +4266 -0
  73. hpcflow/sdk/cli.py +1479 -0
  74. hpcflow/sdk/cli_common.py +385 -0
  75. hpcflow/sdk/config/__init__.py +5 -0
  76. hpcflow/sdk/config/callbacks.py +246 -0
  77. hpcflow/sdk/config/cli.py +388 -0
  78. hpcflow/sdk/config/config.py +1410 -0
  79. hpcflow/sdk/config/config_file.py +501 -0
  80. hpcflow/sdk/config/errors.py +272 -0
  81. hpcflow/sdk/config/types.py +150 -0
  82. hpcflow/sdk/core/__init__.py +38 -0
  83. hpcflow/sdk/core/actions.py +3857 -0
  84. hpcflow/sdk/core/app_aware.py +25 -0
  85. hpcflow/sdk/core/cache.py +224 -0
  86. hpcflow/sdk/core/command_files.py +814 -0
  87. hpcflow/sdk/core/commands.py +424 -0
  88. hpcflow/sdk/core/element.py +2071 -0
  89. hpcflow/sdk/core/enums.py +221 -0
  90. hpcflow/sdk/core/environment.py +256 -0
  91. hpcflow/sdk/core/errors.py +1043 -0
  92. hpcflow/sdk/core/execute.py +207 -0
  93. hpcflow/sdk/core/json_like.py +809 -0
  94. hpcflow/sdk/core/loop.py +1320 -0
  95. hpcflow/sdk/core/loop_cache.py +282 -0
  96. hpcflow/sdk/core/object_list.py +933 -0
  97. hpcflow/sdk/core/parameters.py +3371 -0
  98. hpcflow/sdk/core/rule.py +196 -0
  99. hpcflow/sdk/core/run_dir_files.py +57 -0
  100. hpcflow/sdk/core/skip_reason.py +7 -0
  101. hpcflow/sdk/core/task.py +3792 -0
  102. hpcflow/sdk/core/task_schema.py +993 -0
  103. hpcflow/sdk/core/test_utils.py +538 -0
  104. hpcflow/sdk/core/types.py +447 -0
  105. hpcflow/sdk/core/utils.py +1207 -0
  106. hpcflow/sdk/core/validation.py +87 -0
  107. hpcflow/sdk/core/values.py +477 -0
  108. hpcflow/sdk/core/workflow.py +4820 -0
  109. hpcflow/sdk/core/zarr_io.py +206 -0
  110. hpcflow/sdk/data/__init__.py +13 -0
  111. hpcflow/sdk/data/config_file_schema.yaml +34 -0
  112. hpcflow/sdk/data/config_schema.yaml +260 -0
  113. hpcflow/sdk/data/environments_spec_schema.yaml +21 -0
  114. hpcflow/sdk/data/files_spec_schema.yaml +5 -0
  115. hpcflow/sdk/data/parameters_spec_schema.yaml +7 -0
  116. hpcflow/sdk/data/task_schema_spec_schema.yaml +3 -0
  117. hpcflow/sdk/data/workflow_spec_schema.yaml +22 -0
  118. hpcflow/sdk/demo/__init__.py +3 -0
  119. hpcflow/sdk/demo/cli.py +242 -0
  120. hpcflow/sdk/helper/__init__.py +3 -0
  121. hpcflow/sdk/helper/cli.py +137 -0
  122. hpcflow/sdk/helper/helper.py +300 -0
  123. hpcflow/sdk/helper/watcher.py +192 -0
  124. hpcflow/sdk/log.py +288 -0
  125. hpcflow/sdk/persistence/__init__.py +18 -0
  126. hpcflow/sdk/persistence/base.py +2817 -0
  127. hpcflow/sdk/persistence/defaults.py +6 -0
  128. hpcflow/sdk/persistence/discovery.py +39 -0
  129. hpcflow/sdk/persistence/json.py +954 -0
  130. hpcflow/sdk/persistence/pending.py +948 -0
  131. hpcflow/sdk/persistence/store_resource.py +203 -0
  132. hpcflow/sdk/persistence/types.py +309 -0
  133. hpcflow/sdk/persistence/utils.py +73 -0
  134. hpcflow/sdk/persistence/zarr.py +2388 -0
  135. hpcflow/sdk/runtime.py +320 -0
  136. hpcflow/sdk/submission/__init__.py +3 -0
  137. hpcflow/sdk/submission/enums.py +70 -0
  138. hpcflow/sdk/submission/jobscript.py +2379 -0
  139. hpcflow/sdk/submission/schedulers/__init__.py +281 -0
  140. hpcflow/sdk/submission/schedulers/direct.py +233 -0
  141. hpcflow/sdk/submission/schedulers/sge.py +376 -0
  142. hpcflow/sdk/submission/schedulers/slurm.py +598 -0
  143. hpcflow/sdk/submission/schedulers/utils.py +25 -0
  144. hpcflow/sdk/submission/shells/__init__.py +52 -0
  145. hpcflow/sdk/submission/shells/base.py +229 -0
  146. hpcflow/sdk/submission/shells/bash.py +504 -0
  147. hpcflow/sdk/submission/shells/os_version.py +115 -0
  148. hpcflow/sdk/submission/shells/powershell.py +352 -0
  149. hpcflow/sdk/submission/submission.py +1402 -0
  150. hpcflow/sdk/submission/types.py +140 -0
  151. hpcflow/sdk/typing.py +194 -0
  152. hpcflow/sdk/utils/arrays.py +69 -0
  153. hpcflow/sdk/utils/deferred_file.py +55 -0
  154. hpcflow/sdk/utils/hashing.py +16 -0
  155. hpcflow/sdk/utils/patches.py +31 -0
  156. hpcflow/sdk/utils/strings.py +69 -0
  157. hpcflow/tests/api/test_api.py +32 -0
  158. hpcflow/tests/conftest.py +123 -0
  159. hpcflow/tests/data/__init__.py +0 -0
  160. hpcflow/tests/data/benchmark_N_elements.yaml +6 -0
  161. hpcflow/tests/data/benchmark_script_runner.yaml +26 -0
  162. hpcflow/tests/data/multi_path_sequences.yaml +29 -0
  163. hpcflow/tests/data/workflow_1.json +10 -0
  164. hpcflow/tests/data/workflow_1.yaml +5 -0
  165. hpcflow/tests/data/workflow_1_slurm.yaml +8 -0
  166. hpcflow/tests/data/workflow_1_wsl.yaml +8 -0
  167. hpcflow/tests/data/workflow_test_run_abort.yaml +42 -0
  168. hpcflow/tests/jinja_templates/test_jinja_templates.py +161 -0
  169. hpcflow/tests/programs/test_programs.py +180 -0
  170. hpcflow/tests/schedulers/direct_linux/test_direct_linux_submission.py +12 -0
  171. hpcflow/tests/schedulers/sge/test_sge_submission.py +36 -0
  172. hpcflow/tests/schedulers/slurm/test_slurm_submission.py +14 -0
  173. hpcflow/tests/scripts/test_input_file_generators.py +282 -0
  174. hpcflow/tests/scripts/test_main_scripts.py +1361 -0
  175. hpcflow/tests/scripts/test_non_snippet_script.py +46 -0
  176. hpcflow/tests/scripts/test_ouput_file_parsers.py +353 -0
  177. hpcflow/tests/shells/wsl/test_wsl_submission.py +14 -0
  178. hpcflow/tests/unit/test_action.py +1066 -0
  179. hpcflow/tests/unit/test_action_rule.py +24 -0
  180. hpcflow/tests/unit/test_app.py +132 -0
  181. hpcflow/tests/unit/test_cache.py +46 -0
  182. hpcflow/tests/unit/test_cli.py +172 -0
  183. hpcflow/tests/unit/test_command.py +377 -0
  184. hpcflow/tests/unit/test_config.py +195 -0
  185. hpcflow/tests/unit/test_config_file.py +162 -0
  186. hpcflow/tests/unit/test_element.py +666 -0
  187. hpcflow/tests/unit/test_element_iteration.py +88 -0
  188. hpcflow/tests/unit/test_element_set.py +158 -0
  189. hpcflow/tests/unit/test_group.py +115 -0
  190. hpcflow/tests/unit/test_input_source.py +1479 -0
  191. hpcflow/tests/unit/test_input_value.py +398 -0
  192. hpcflow/tests/unit/test_jobscript_unit.py +757 -0
  193. hpcflow/tests/unit/test_json_like.py +1247 -0
  194. hpcflow/tests/unit/test_loop.py +2674 -0
  195. hpcflow/tests/unit/test_meta_task.py +325 -0
  196. hpcflow/tests/unit/test_multi_path_sequences.py +259 -0
  197. hpcflow/tests/unit/test_object_list.py +116 -0
  198. hpcflow/tests/unit/test_parameter.py +243 -0
  199. hpcflow/tests/unit/test_persistence.py +664 -0
  200. hpcflow/tests/unit/test_resources.py +243 -0
  201. hpcflow/tests/unit/test_run.py +286 -0
  202. hpcflow/tests/unit/test_run_directories.py +29 -0
  203. hpcflow/tests/unit/test_runtime.py +9 -0
  204. hpcflow/tests/unit/test_schema_input.py +372 -0
  205. hpcflow/tests/unit/test_shell.py +129 -0
  206. hpcflow/tests/unit/test_slurm.py +39 -0
  207. hpcflow/tests/unit/test_submission.py +502 -0
  208. hpcflow/tests/unit/test_task.py +2560 -0
  209. hpcflow/tests/unit/test_task_schema.py +182 -0
  210. hpcflow/tests/unit/test_utils.py +616 -0
  211. hpcflow/tests/unit/test_value_sequence.py +549 -0
  212. hpcflow/tests/unit/test_values.py +91 -0
  213. hpcflow/tests/unit/test_workflow.py +827 -0
  214. hpcflow/tests/unit/test_workflow_template.py +186 -0
  215. hpcflow/tests/unit/utils/test_arrays.py +40 -0
  216. hpcflow/tests/unit/utils/test_deferred_file_writer.py +34 -0
  217. hpcflow/tests/unit/utils/test_hashing.py +65 -0
  218. hpcflow/tests/unit/utils/test_patches.py +5 -0
  219. hpcflow/tests/unit/utils/test_redirect_std.py +50 -0
  220. hpcflow/tests/unit/utils/test_strings.py +97 -0
  221. hpcflow/tests/workflows/__init__.py +0 -0
  222. hpcflow/tests/workflows/test_directory_structure.py +31 -0
  223. hpcflow/tests/workflows/test_jobscript.py +355 -0
  224. hpcflow/tests/workflows/test_run_status.py +198 -0
  225. hpcflow/tests/workflows/test_skip_downstream.py +696 -0
  226. hpcflow/tests/workflows/test_submission.py +140 -0
  227. hpcflow/tests/workflows/test_workflows.py +564 -0
  228. hpcflow/tests/workflows/test_zip.py +18 -0
  229. hpcflow/viz_demo.ipynb +6794 -0
  230. hpcflow-0.2.0a271.dist-info/LICENSE +375 -0
  231. hpcflow-0.2.0a271.dist-info/METADATA +65 -0
  232. hpcflow-0.2.0a271.dist-info/RECORD +237 -0
  233. {hpcflow-0.1.15.dist-info → hpcflow-0.2.0a271.dist-info}/WHEEL +4 -5
  234. hpcflow-0.2.0a271.dist-info/entry_points.txt +6 -0
  235. hpcflow/api.py +0 -490
  236. hpcflow/archive/archive.py +0 -307
  237. hpcflow/archive/cloud/cloud.py +0 -45
  238. hpcflow/archive/cloud/errors.py +0 -9
  239. hpcflow/archive/cloud/providers/dropbox.py +0 -427
  240. hpcflow/archive/errors.py +0 -5
  241. hpcflow/base_db.py +0 -4
  242. hpcflow/config.py +0 -233
  243. hpcflow/copytree.py +0 -66
  244. hpcflow/data/examples/_config.yml +0 -14
  245. hpcflow/data/examples/damask/demo/1.run.yml +0 -4
  246. hpcflow/data/examples/damask/demo/2.process.yml +0 -29
  247. hpcflow/data/examples/damask/demo/geom.geom +0 -2052
  248. hpcflow/data/examples/damask/demo/load.load +0 -1
  249. hpcflow/data/examples/damask/demo/material.config +0 -185
  250. hpcflow/data/examples/damask/inputs/geom.geom +0 -2052
  251. hpcflow/data/examples/damask/inputs/load.load +0 -1
  252. hpcflow/data/examples/damask/inputs/material.config +0 -185
  253. hpcflow/data/examples/damask/profiles/_variable_lookup.yml +0 -21
  254. hpcflow/data/examples/damask/profiles/damask.yml +0 -4
  255. hpcflow/data/examples/damask/profiles/damask_process.yml +0 -8
  256. hpcflow/data/examples/damask/profiles/damask_run.yml +0 -5
  257. hpcflow/data/examples/damask/profiles/default.yml +0 -6
  258. hpcflow/data/examples/thinking.yml +0 -177
  259. hpcflow/errors.py +0 -2
  260. hpcflow/init_db.py +0 -37
  261. hpcflow/models.py +0 -2595
  262. hpcflow/nesting.py +0 -9
  263. hpcflow/profiles.py +0 -455
  264. hpcflow/project.py +0 -81
  265. hpcflow/scheduler.py +0 -322
  266. hpcflow/utils.py +0 -103
  267. hpcflow/validation.py +0 -166
  268. hpcflow/variables.py +0 -543
  269. hpcflow-0.1.15.dist-info/METADATA +0 -168
  270. hpcflow-0.1.15.dist-info/RECORD +0 -45
  271. hpcflow-0.1.15.dist-info/entry_points.txt +0 -8
  272. hpcflow-0.1.15.dist-info/top_level.txt +0 -1
  273. /hpcflow/{archive → data/jinja_templates}/__init__.py +0 -0
  274. /hpcflow/{archive/cloud → data/programs}/__init__.py +0 -0
  275. /hpcflow/{archive/cloud/providers → data/workflows}/__init__.py +0 -0
@@ -0,0 +1,2379 @@
1
+ """
2
+ Model of information submitted to a scheduler.
3
+ """
4
+
5
+ from __future__ import annotations
6
+ from collections import defaultdict
7
+
8
+ import os
9
+ import shutil
10
+ import socket
11
+ import subprocess
12
+ from textwrap import dedent, indent
13
+ from typing import TextIO, cast, overload, TYPE_CHECKING
14
+ from typing_extensions import override
15
+
16
+ import numpy as np
17
+ from hpcflow.sdk.core import SKIPPED_EXIT_CODE
18
+ from hpcflow.sdk.core.enums import EARStatus
19
+ from hpcflow.sdk.core.errors import (
20
+ JobscriptSubmissionFailure,
21
+ NotSubmitMachineError,
22
+ )
23
+
24
+ from hpcflow.sdk.typing import hydrate
25
+ from hpcflow.sdk.core.json_like import ChildObjectSpec, JSONLike
26
+ from hpcflow.sdk.core.utils import nth_value, parse_timestamp, current_timestamp
27
+ from hpcflow.sdk.utils.strings import extract_py_from_future_imports
28
+ from hpcflow.sdk.log import TimeIt
29
+ from hpcflow.sdk.submission.schedulers import QueuedScheduler
30
+ from hpcflow.sdk.submission.schedulers.direct import DirectScheduler
31
+ from hpcflow.sdk.submission.shells import get_shell, DEFAULT_SHELL_NAMES
32
+
33
+ if TYPE_CHECKING:
34
+ from collections.abc import Iterable, Iterator, Mapping, Sequence
35
+ from datetime import datetime
36
+ from pathlib import Path
37
+ from typing import Any, ClassVar, Literal
38
+ from typing_extensions import TypeIs
39
+ from numpy.typing import NDArray, ArrayLike
40
+ from ..core.actions import ElementActionRun
41
+ from ..core.element import ElementResources
42
+ from ..core.loop_cache import LoopIndex
43
+ from ..core.types import JobscriptSubmissionFailureArgs, BlockActionKey
44
+ from ..core.workflow import WorkflowTask, Workflow
45
+ from ..persistence.base import PersistentStore
46
+ from .submission import Submission
47
+ from .shells.base import Shell
48
+ from .schedulers import Scheduler
49
+ from .enums import JobscriptElementState
50
+ from .types import (
51
+ JobScriptCreationArguments,
52
+ JobScriptDescriptor,
53
+ ResolvedJobscriptBlockDependencies,
54
+ SchedulerRef,
55
+ VersionInfo,
56
+ )
57
+ from ..core.cache import ObjectCache
58
+ from hpcflow.sdk.submission.submission import JOBSCRIPT_SUBMIT_TIME_KEYS
59
+
60
+
61
+ def is_jobscript_array(
62
+ resources: ElementResources, num_elements: int, store: PersistentStore
63
+ ) -> bool:
64
+ """Return True if a job array should be used for the specified `ElementResources`."""
65
+ if resources.scheduler in ("direct", "direct_posix"):
66
+ if resources.use_job_array:
67
+ raise ValueError(
68
+ f"`use_job_array` not supported by scheduler: {resources.scheduler!r}"
69
+ )
70
+ return False
71
+
72
+ if resources.combine_scripts:
73
+ return False
74
+
75
+ run_parallelism = store._features.EAR_parallelism
76
+ if resources.use_job_array is None:
77
+ if num_elements > 1 and run_parallelism:
78
+ return True
79
+ else:
80
+ return False
81
+ else:
82
+ if resources.use_job_array and not run_parallelism:
83
+ raise ValueError(
84
+ f"Store type {store!r} does not support element parallelism, so jobs "
85
+ f"cannot be submitted as scheduler arrays."
86
+ )
87
+ return resources.use_job_array
88
+
89
+
90
+ @TimeIt.decorator
91
+ def generate_EAR_resource_map(
92
+ task: WorkflowTask,
93
+ loop_idx: LoopIndex[str, int],
94
+ cache: ObjectCache,
95
+ ) -> tuple[Sequence[ElementResources], Sequence[int], NDArray, NDArray]:
96
+ """
97
+ Generate an integer array whose rows represent actions and columns represent task
98
+ elements and whose values index unique resources.
99
+ """
100
+ none_val = -1
101
+ resources: list[ElementResources] = []
102
+ resource_hashes: list[int] = []
103
+
104
+ arr_shape = (task.num_actions, task.num_elements)
105
+ resource_map = np.empty(arr_shape, dtype=int)
106
+ EAR_ID_map = np.empty(arr_shape, dtype=int)
107
+ resource_map[:] = none_val
108
+ EAR_ID_map[:] = none_val
109
+
110
+ assert cache.elements is not None
111
+ assert cache.iterations is not None
112
+
113
+ for elem_id in task.element_IDs:
114
+ element = cache.elements[elem_id]
115
+ for iter_ID_i in element.iteration_IDs:
116
+ iter_i = cache.iterations[iter_ID_i]
117
+ if iter_i.loop_idx != loop_idx:
118
+ continue
119
+ if iter_i.EARs_initialised: # not strictly needed (actions will be empty)
120
+ for act_idx, action in iter_i.actions.items():
121
+ for run in action.runs:
122
+ if run.status == EARStatus.pending:
123
+ # TODO: consider `time_limit`s
124
+ res_hash = run.resources.get_jobscript_hash()
125
+ if res_hash not in resource_hashes:
126
+ resource_hashes.append(res_hash)
127
+ resources.append(run.resources)
128
+ resource_map[act_idx][element.index] = resource_hashes.index(
129
+ res_hash
130
+ )
131
+ EAR_ID_map[act_idx, element.index] = run.id_
132
+
133
+ # set defaults for and validate unique resources:
134
+ for res in resources:
135
+ res.set_defaults()
136
+ res.validate_against_machine()
137
+
138
+ return (
139
+ resources,
140
+ resource_hashes,
141
+ resource_map,
142
+ EAR_ID_map,
143
+ )
144
+
145
+
146
+ @TimeIt.decorator
147
+ def group_resource_map_into_jobscripts(
148
+ resource_map: ArrayLike,
149
+ none_val: Any = -1,
150
+ ) -> tuple[list[JobScriptDescriptor], NDArray]:
151
+ """
152
+ Convert a resource map into a plan for what elements to group together into jobscripts.
153
+ """
154
+ resource_map_ = np.asanyarray(resource_map)
155
+ resource_idx = np.unique(resource_map_)
156
+ jobscripts: list[JobScriptDescriptor] = []
157
+ allocated = np.zeros_like(resource_map_)
158
+ js_map = np.ones_like(resource_map_, dtype=float) * np.nan
159
+ nones_bool: NDArray = resource_map_ == none_val
160
+ stop = False
161
+ for act_idx in range(resource_map_.shape[0]):
162
+ for res_i in resource_idx:
163
+ if res_i == none_val:
164
+ continue
165
+
166
+ if res_i not in resource_map_[act_idx]:
167
+ continue
168
+
169
+ resource_map_[nones_bool] = res_i
170
+ diff = np.cumsum(np.abs(np.diff(resource_map_[act_idx:], axis=0)), axis=0)
171
+
172
+ elem_bool = np.logical_and(
173
+ resource_map_[act_idx] == res_i, allocated[act_idx] == False
174
+ )
175
+ elem_idx = np.where(elem_bool)[0]
176
+ act_elem_bool = np.logical_and(elem_bool, nones_bool[act_idx] == False)
177
+ act_elem_idx: tuple[NDArray, ...] = np.where(act_elem_bool)
178
+
179
+ # add elements from downstream actions:
180
+ ds_bool = np.logical_and(
181
+ diff[:, elem_idx] == 0,
182
+ nones_bool[act_idx + 1 :, elem_idx] == False,
183
+ )
184
+ ds_act_idx: NDArray
185
+ ds_elem_idx: NDArray
186
+ ds_act_idx, ds_elem_idx = np.where(ds_bool)
187
+ ds_act_idx += act_idx + 1
188
+ ds_elem_idx = elem_idx[ds_elem_idx]
189
+
190
+ EARs_by_elem: dict[int, list[int]] = {
191
+ k.item(): [act_idx] for k in act_elem_idx[0]
192
+ }
193
+ for ds_a, ds_e in zip(ds_act_idx, ds_elem_idx):
194
+ EARs_by_elem.setdefault(ds_e.item(), []).append(ds_a.item())
195
+
196
+ EARs = np.vstack([np.ones_like(act_elem_idx) * act_idx, act_elem_idx])
197
+ EARs = np.hstack([EARs, np.array([ds_act_idx, ds_elem_idx])])
198
+
199
+ if not EARs.size:
200
+ continue
201
+
202
+ js: JobScriptDescriptor = {
203
+ "resources": res_i,
204
+ "elements": dict(sorted(EARs_by_elem.items(), key=lambda x: x[0])),
205
+ }
206
+ allocated[EARs[0], EARs[1]] = True
207
+ js_map[EARs[0], EARs[1]] = len(jobscripts)
208
+ jobscripts.append(js)
209
+
210
+ if np.all(allocated[~nones_bool]):
211
+ stop = True
212
+ break
213
+
214
+ if stop:
215
+ break
216
+
217
+ resource_map_[nones_bool] = none_val
218
+
219
+ return jobscripts, js_map
220
+
221
+
222
+ @TimeIt.decorator
223
+ def resolve_jobscript_dependencies(
224
+ jobscripts: Mapping[int, JobScriptCreationArguments],
225
+ element_deps: Mapping[int, Mapping[int, Sequence[int]]],
226
+ ) -> Mapping[int, dict[int, ResolvedJobscriptBlockDependencies]]:
227
+ """
228
+ Discover concrete dependencies between jobscripts.
229
+ """
230
+ # first pass is to find the mappings between jobscript elements:
231
+ jobscript_deps: dict[int, dict[int, ResolvedJobscriptBlockDependencies]] = {}
232
+ for js_idx, elem_deps in element_deps.items():
233
+ # keys of new dict are other jobscript indices on which this jobscript (js_idx)
234
+ # depends:
235
+ jobscript_deps[js_idx] = {}
236
+
237
+ for js_elem_idx_i, EAR_deps_i in elem_deps.items():
238
+ # locate which jobscript elements this jobscript element depends on:
239
+ for EAR_dep_j in EAR_deps_i:
240
+ for js_k_idx, js_k in jobscripts.items():
241
+ if js_k_idx == js_idx:
242
+ break
243
+
244
+ if EAR_dep_j in js_k["EAR_ID"]:
245
+ if js_k_idx not in jobscript_deps[js_idx]:
246
+ jobscript_deps[js_idx][js_k_idx] = {"js_element_mapping": {}}
247
+
248
+ jobscript_deps[js_idx][js_k_idx]["js_element_mapping"].setdefault(
249
+ js_elem_idx_i, []
250
+ )
251
+
252
+ # retrieve column index, which is the JS-element index:
253
+ js_elem_idx_k: int = np.where(
254
+ np.any(js_k["EAR_ID"] == EAR_dep_j, axis=0)
255
+ )[0][0].item()
256
+
257
+ # add js dependency element-mapping:
258
+ if (
259
+ js_elem_idx_k
260
+ not in jobscript_deps[js_idx][js_k_idx]["js_element_mapping"][
261
+ js_elem_idx_i
262
+ ]
263
+ ):
264
+ jobscript_deps[js_idx][js_k_idx]["js_element_mapping"][
265
+ js_elem_idx_i
266
+ ].append(js_elem_idx_k)
267
+
268
+ # next we can determine if two jobscripts have a one-to-one element mapping, which
269
+ # means they can be submitted with a "job array" dependency relationship:
270
+ for js_i_idx, deps_i in jobscript_deps.items():
271
+ for js_k_idx, deps_j in deps_i.items():
272
+ # is this an array dependency?
273
+
274
+ js_i_num_js_elements = jobscripts[js_i_idx]["EAR_ID"].shape[1]
275
+ js_k_num_js_elements = jobscripts[js_k_idx]["EAR_ID"].shape[1]
276
+
277
+ is_all_i_elems = sorted(set(deps_j["js_element_mapping"])) == list(
278
+ range(js_i_num_js_elements)
279
+ )
280
+
281
+ is_all_k_single = set(
282
+ len(i) for i in deps_j["js_element_mapping"].values()
283
+ ) == {1}
284
+
285
+ is_all_k_elems = sorted(
286
+ i[0] for i in deps_j["js_element_mapping"].values()
287
+ ) == list(range(js_k_num_js_elements))
288
+
289
+ is_arr = is_all_i_elems and is_all_k_single and is_all_k_elems
290
+ jobscript_deps[js_i_idx][js_k_idx]["is_array"] = is_arr
291
+
292
+ return jobscript_deps
293
+
294
+
295
+ def _reindex_dependencies(
296
+ jobscripts: Mapping[int, JobScriptCreationArguments],
297
+ from_idx: int,
298
+ to_idx: int,
299
+ ):
300
+ for ds_js_idx, ds_js in jobscripts.items():
301
+ if ds_js_idx <= from_idx:
302
+ continue
303
+ deps = ds_js["dependencies"]
304
+ if from_idx in deps:
305
+ deps[to_idx] = deps.pop(from_idx)
306
+
307
+
308
+ @TimeIt.decorator
309
+ def merge_jobscripts_across_tasks(
310
+ jobscripts: Mapping[int, JobScriptCreationArguments],
311
+ ) -> Mapping[int, JobScriptCreationArguments]:
312
+ """Try to merge jobscripts between tasks.
313
+
314
+ This is possible if two jobscripts share the same resources and have an array
315
+ dependency (i.e. one-to-one element dependency mapping).
316
+
317
+ """
318
+
319
+ # The set of IDs of dicts that we've merged, allowing us to not keep that info in
320
+ # the dicts themselves.
321
+ merged: set[int] = set()
322
+
323
+ for js_idx, js in jobscripts.items():
324
+ if not js["dependencies"]:
325
+ continue
326
+
327
+ closest_idx = cast("int", max(js["dependencies"]))
328
+ closest_js = jobscripts[closest_idx]
329
+ other_deps = {k: v for k, v in js["dependencies"].items() if k != closest_idx}
330
+
331
+ # if all `other_deps` are also found within `closest_js`'s dependencies, then we
332
+ # can merge `js` into `closest_js`:
333
+ merge = True
334
+ for dep_idx, dep_i in other_deps.items():
335
+ try:
336
+ if closest_js["dependencies"][dep_idx] != dep_i:
337
+ merge = False
338
+ except KeyError:
339
+ merge = False
340
+
341
+ if merge:
342
+ js_j = closest_js # the jobscript we are merging `js` into
343
+ js_j_idx = closest_idx
344
+ dep_info = js["dependencies"][js_j_idx]
345
+
346
+ # can only merge if resources are the same and is array dependency:
347
+ if js["resource_hash"] == js_j["resource_hash"] and dep_info["is_array"]:
348
+ num_loop_idx = len(
349
+ js_j["task_loop_idx"]
350
+ ) # TODO: should this be: `js_j["task_loop_idx"][0]`?
351
+
352
+ # append task_insert_IDs
353
+ js_j["task_insert_IDs"].append(js["task_insert_IDs"][0])
354
+ js_j["task_loop_idx"].append(js["task_loop_idx"][0])
355
+
356
+ add_acts = [(a, b, num_loop_idx) for a, b, _ in js["task_actions"]]
357
+
358
+ js_j["task_actions"].extend(add_acts)
359
+ for k, v in js["task_elements"].items():
360
+ js_j["task_elements"][k].extend(v)
361
+
362
+ # append to elements and elements_idx list
363
+ js_j["EAR_ID"] = np.vstack((js_j["EAR_ID"], js["EAR_ID"]))
364
+
365
+ # mark this js as defunct
366
+ merged.add(id(js))
367
+
368
+ # update dependencies of any downstream jobscripts that refer to this js
369
+ _reindex_dependencies(jobscripts, js_idx, js_j_idx)
370
+
371
+ # remove is_merged jobscripts:
372
+ return {k: v for k, v in jobscripts.items() if id(v) not in merged}
373
+
374
+
375
+ @TimeIt.decorator
376
+ def resolve_jobscript_blocks(
377
+ jobscripts: Mapping[int, JobScriptCreationArguments],
378
+ ) -> list[dict[str, Any]]:
379
+ """For contiguous, dependent, non-array jobscripts with identical resource
380
+ requirements, combine into multi-block jobscripts.
381
+
382
+ Parameters
383
+ ----------
384
+ jobscripts
385
+ Dict whose values must be dicts with keys "is_array", "resource_hash" and
386
+ "dependencies".
387
+ run_parallelism
388
+ True if the store supports run parallelism
389
+
390
+ """
391
+ js_new: list[list[JobScriptCreationArguments]] = (
392
+ []
393
+ ) # TODO: not the same type, e.g. dependencies have tuple keys,
394
+ new_idx: dict[int, tuple[int, int]] = (
395
+ {}
396
+ ) # track new positions by new jobscript index and block index
397
+ new_idx_inv: dict[int, list[int]] = defaultdict(list)
398
+ prev_hash = None
399
+ blocks: list[JobScriptCreationArguments] = []
400
+ js_deps_rec: dict[int, set[int]] = {} # recursive
401
+ for js_idx, js_i in jobscripts.items():
402
+
403
+ cur_js_idx = len(js_new)
404
+ new_deps_js_j = {
405
+ new_idx[i][0] for i in cast("Sequence[int]", js_i["dependencies"])
406
+ }
407
+ new_deps_js_j_rec = [
408
+ k for i in new_deps_js_j for j in new_idx_inv[i] for k in js_deps_rec[j]
409
+ ]
410
+
411
+ js_deps_rec[js_idx] = new_deps_js_j.union(new_deps_js_j_rec)
412
+
413
+ # recursive dependencies of js_i (which we're looking to merge), excluding the
414
+ # dependency on the current jobscript:
415
+ js_j_deps_rec_no_cur = js_deps_rec[js_idx] - set([cur_js_idx])
416
+
417
+ # recursive dependencies of the current jobscript:
418
+ cur_deps_rec = {
419
+ j for i in new_idx_inv[cur_js_idx] for j in js_deps_rec[i] if j != cur_js_idx
420
+ }
421
+
422
+ # can we mege js_i into the current jobscript, as far as dependencies are
423
+ # concerned?
424
+ deps_mergable = cur_js_idx in new_deps_js_j
425
+ if deps_mergable and js_j_deps_rec_no_cur:
426
+ deps_mergable = js_j_deps_rec_no_cur == cur_deps_rec
427
+
428
+ if js_i["is_array"]:
429
+ # array jobs cannot be merged into the same jobscript
430
+
431
+ # append existing block:
432
+ if blocks:
433
+ js_new.append(blocks)
434
+ prev_hash = None
435
+ blocks = []
436
+
437
+ new_idx[js_idx] = (len(js_new), 0)
438
+ new_idx_inv[len(js_new)].append(js_idx)
439
+ js_new.append([js_i])
440
+ continue
441
+
442
+ if js_idx == 0 or prev_hash is None:
443
+ # (note: zeroth index will always exist)
444
+
445
+ # start a new block:
446
+ blocks.append(js_i)
447
+ new_idx[js_idx] = (len(js_new), len(blocks) - 1)
448
+ new_idx_inv[len(js_new)].append(js_idx)
449
+
450
+ # set resource hash to compare with the next jobscript
451
+ prev_hash = js_i["resource_hash"]
452
+
453
+ elif js_i["resource_hash"] == prev_hash and deps_mergable:
454
+ # merge with previous jobscript by adding another block
455
+ # only merge if this jobscript's dependencies include the current jobscript,
456
+ # and any other dependencies are included in the current jobscript's
457
+ # dependencies
458
+ blocks.append(js_i)
459
+ new_idx[js_idx] = (len(js_new), len(blocks) - 1)
460
+ new_idx_inv[len(js_new)].append(js_idx)
461
+
462
+ else:
463
+ # cannot merge, append the new jobscript data:
464
+ js_new.append(blocks)
465
+
466
+ # start a new block:
467
+ blocks = [js_i]
468
+ new_idx[js_idx] = (len(js_new), len(blocks) - 1)
469
+ new_idx_inv[len(js_new)].append(js_idx)
470
+
471
+ # set resource hash to compare with the next jobscript
472
+ prev_hash = js_i["resource_hash"]
473
+
474
+ # append remaining blocks:
475
+ if blocks:
476
+ js_new.append(blocks)
477
+ prev_hash = None
478
+ blocks = []
479
+
480
+ # re-index dependencies:
481
+ js_new_: list[dict[str, Any]] = []
482
+ for js_i_idx, js_new_i in enumerate(js_new):
483
+
484
+ resources = None
485
+ is_array = None
486
+ for block_j in js_new_i:
487
+ for k, v in new_idx.items():
488
+ dep_data = block_j["dependencies"].pop(k, None)
489
+ if dep_data:
490
+ block_j["dependencies"][v] = dep_data
491
+
492
+ del block_j["resource_hash"]
493
+ resources = block_j.pop("resources", None)
494
+ is_array = block_j.pop("is_array")
495
+
496
+ js_new_.append(
497
+ {
498
+ "resources": resources,
499
+ "is_array": is_array,
500
+ "blocks": js_new[js_i_idx],
501
+ }
502
+ )
503
+
504
+ return js_new_
505
+
506
+
507
+ @hydrate
508
+ class JobscriptBlock(JSONLike):
509
+ """A rectangular block of element-actions to run within a jobscript.
510
+
511
+ Parameters
512
+ ----------
513
+ task_insert_IDs: list[int]
514
+ The task insertion IDs.
515
+ task_actions: list[tuple]
516
+ The actions of the tasks.
517
+ ``task insert ID, action_idx, index into task_loop_idx`` for each ``JS_ACTION_IDX``
518
+ task_elements: dict[int, list[int]]
519
+ The elements of the tasks.
520
+ Maps ``JS_ELEMENT_IDX`` to list of ``TASK_ELEMENT_IDX`` for each ``TASK_INSERT_ID``
521
+ EAR_ID:
522
+ Element action run information.
523
+ task_loop_idx: list[dict]
524
+ Description of what loops are in play.
525
+ dependencies: dict[tuple[int, int], dict]
526
+ Description of dependencies. Keys are tuples of (jobscript index,
527
+ jobscript-block index) of the dependency.
528
+ index: int
529
+ The index of the block within the parent jobscript.
530
+ jobscript: ~hpcflow.app.Jobscript
531
+ The parent jobscript.
532
+
533
+ """
534
+
535
+ def __init__(
536
+ self,
537
+ index: int,
538
+ task_insert_IDs: list[int],
539
+ task_loop_idx: list[dict[str, int]],
540
+ task_actions: list[tuple[int, int, int]] | None = None,
541
+ task_elements: dict[int, list[int]] | None = None,
542
+ EAR_ID: NDArray | None = None,
543
+ dependencies: (
544
+ dict[tuple[int, int], ResolvedJobscriptBlockDependencies] | None
545
+ ) = None,
546
+ jobscript: Jobscript | None = None,
547
+ ):
548
+ self.jobscript = jobscript
549
+ self._index = index
550
+ self._task_insert_IDs = task_insert_IDs
551
+ self._task_actions = task_actions
552
+ self._task_elements = task_elements
553
+ self._task_loop_idx = task_loop_idx
554
+ self._EAR_ID = EAR_ID
555
+ self._dependencies = dependencies
556
+
557
+ self._all_EARs = None # assigned on first access to `all_EARs` property
558
+
559
+ @property
560
+ def index(self) -> int:
561
+ return self._index
562
+
563
+ @property
564
+ def submission(self) -> Submission:
565
+ assert self.jobscript is not None
566
+ return self.jobscript.submission
567
+
568
+ @property
569
+ def task_insert_IDs(self) -> Sequence[int]:
570
+ """
571
+ The insertion IDs of tasks in this jobscript-block.
572
+ """
573
+ return self._task_insert_IDs
574
+
575
+ @property
576
+ @TimeIt.decorator
577
+ def task_actions(self) -> NDArray:
578
+ """
579
+ The IDs of actions of each task in this jobscript-block.
580
+ """
581
+ assert self.jobscript is not None
582
+ return self.workflow._store.get_jobscript_block_task_actions_array(
583
+ sub_idx=self.submission.index,
584
+ js_idx=self.jobscript.index,
585
+ blk_idx=self.index,
586
+ task_actions_arr=self._task_actions,
587
+ )
588
+
589
+ @property
590
+ @TimeIt.decorator
591
+ def task_elements(self) -> Mapping[int, Sequence[int]]:
592
+ """
593
+ The IDs of elements of each task in this jobscript-block.
594
+ """
595
+ assert self.jobscript is not None
596
+ return self.workflow._store.get_jobscript_block_task_elements_map(
597
+ sub_idx=self.submission.index,
598
+ js_idx=self.jobscript.index,
599
+ blk_idx=self.index,
600
+ task_elems_map=self._task_elements,
601
+ )
602
+
603
+ @property
604
+ @TimeIt.decorator
605
+ def EAR_ID(self) -> NDArray:
606
+ """
607
+ The array of EAR IDs in this jobscript-block.
608
+ """
609
+ assert self.jobscript is not None
610
+ return self.workflow._store.get_jobscript_block_run_ID_array(
611
+ sub_idx=self.submission.index,
612
+ js_idx=self.jobscript.index,
613
+ blk_idx=self.index,
614
+ run_ID_arr=self._EAR_ID,
615
+ )
616
+
617
+ @property
618
+ @TimeIt.decorator
619
+ def dependencies(
620
+ self,
621
+ ) -> Mapping[tuple[int, int], ResolvedJobscriptBlockDependencies]:
622
+ """
623
+ The dependency descriptor.
624
+ """
625
+ assert self.jobscript is not None
626
+ return self.workflow._store.get_jobscript_block_dependencies(
627
+ sub_idx=self.submission.index,
628
+ js_idx=self.jobscript.index,
629
+ blk_idx=self.index,
630
+ js_dependencies=self._dependencies,
631
+ )
632
+
633
+ @property
634
+ def task_loop_idx(self) -> Sequence[Mapping[str, int]]:
635
+ """
636
+ The description of where various task loops are.
637
+ """
638
+ return self._task_loop_idx
639
+
640
+ @property
641
+ @TimeIt.decorator
642
+ def num_actions(self) -> int:
643
+ """
644
+ The maximal number of actions in the jobscript-block.
645
+ """
646
+ return self.EAR_ID.shape[0]
647
+
648
+ @property
649
+ @TimeIt.decorator
650
+ def num_elements(self) -> int:
651
+ """
652
+ The maximal number of elements in the jobscript-block.
653
+ """
654
+ return self.EAR_ID.shape[1]
655
+
656
+ @property
657
+ def workflow(self) -> Workflow:
658
+ """
659
+ The associated workflow.
660
+ """
661
+ assert self.jobscript is not None
662
+ return self.jobscript.workflow
663
+
664
+ @property
665
+ @TimeIt.decorator
666
+ def all_EARs(self) -> Sequence[ElementActionRun]:
667
+ """
668
+ Description of EAR information for this jobscript-block.
669
+ """
670
+ assert self.jobscript is not None
671
+ return [i for i in self.jobscript.all_EARs if i.id_ in self.EAR_ID]
672
+
673
+ @override
674
+ def _postprocess_to_dict(self, d: dict[str, Any]) -> dict[str, Any]:
675
+ dct = super()._postprocess_to_dict(d)
676
+ del dct["_all_EARs"]
677
+ dct["_dependencies"] = [[list(k), v] for k, v in self.dependencies.items()]
678
+ dct = {k.lstrip("_"): v for k, v in dct.items()}
679
+ dct["EAR_ID"] = cast("NDArray", dct["EAR_ID"]).tolist()
680
+ return dct
681
+
682
+ @classmethod
683
+ def from_json_like(cls, json_like, shared_data=None):
684
+ json_like["EAR_ID"] = (
685
+ np.array(json_like["EAR_ID"]) if json_like["EAR_ID"] is not None else None
686
+ )
687
+ if json_like["dependencies"] is not None:
688
+ # transform list to dict with tuple keys, and transform string keys in
689
+ # `js_element_mapping` to integers:
690
+ deps_processed = {}
691
+ for i in json_like["dependencies"]:
692
+ deps_processed_i = {
693
+ "js_element_mapping": {
694
+ int(k): v for k, v in i[1]["js_element_mapping"].items()
695
+ },
696
+ "is_array": i[1]["is_array"],
697
+ }
698
+ deps_processed[tuple(i[0])] = deps_processed_i
699
+ json_like["dependencies"] = deps_processed
700
+
701
+ return super().from_json_like(json_like, shared_data)
702
+
703
+ def _get_EARs_arr(self) -> NDArray:
704
+ """
705
+ Get all associated EAR objects as a 2D array.
706
+ """
707
+ return np.array(self.all_EARs).reshape(self.EAR_ID.shape)
708
+
709
+ def get_task_loop_idx_array(self) -> NDArray:
710
+ """
711
+ Get an array of task loop indices.
712
+ """
713
+ loop_idx = np.empty_like(self.EAR_ID)
714
+ loop_idx[:] = np.array([i[2] for i in self.task_actions]).reshape(
715
+ (len(self.task_actions), 1)
716
+ )
717
+ return loop_idx
718
+
719
+ @TimeIt.decorator
720
+ def write_EAR_ID_file(self, fp: TextIO):
721
+ """Write a text file with `num_elements` lines and `num_actions` delimited tokens
722
+ per line, representing whether a given EAR must be executed."""
723
+ assert self.jobscript is not None
724
+ # can't specify "open" newline if we pass the file name only, so pass handle:
725
+ np.savetxt(
726
+ fname=fp,
727
+ X=(self.EAR_ID).T,
728
+ fmt="%.0f",
729
+ delimiter=self.jobscript._EAR_files_delimiter,
730
+ )
731
+
732
+
733
+ @hydrate
734
+ class Jobscript(JSONLike):
735
+ """
736
+ A group of actions that are submitted together to be executed by the underlying job
737
+ management system as a single unit.
738
+
739
+ Parameters
740
+ ----------
741
+ task_insert_IDs: list[int]
742
+ The task insertion IDs.
743
+ task_actions: list[tuple]
744
+ The actions of the tasks.
745
+ ``task insert ID, action_idx, index into task_loop_idx`` for each ``JS_ACTION_IDX``
746
+ task_elements: dict[int, list[int]]
747
+ The elements of the tasks.
748
+ Maps ``JS_ELEMENT_IDX`` to list of ``TASK_ELEMENT_IDX`` for each ``TASK_INSERT_ID``
749
+ EAR_ID:
750
+ Element action run information.
751
+ resources: ~hpcflow.app.ElementResources
752
+ Resources to use
753
+ task_loop_idx: list[dict]
754
+ Description of what loops are in play.
755
+ dependencies: dict[int, dict]
756
+ Description of dependencies.
757
+ submit_time: datetime
758
+ When the jobscript was submitted, if known.
759
+ submit_hostname: str
760
+ Where the jobscript was submitted, if known.
761
+ submit_machine: str
762
+ Description of what the jobscript was submitted to, if known.
763
+ submit_cmdline: str
764
+ The command line used to do the commit, if known.
765
+ scheduler_job_ID: str
766
+ The job ID from the scheduler, if known.
767
+ process_ID: int
768
+ The process ID of the subprocess, if known.
769
+ version_info: dict[str, ...]
770
+ Version info about the target system.
771
+ os_name: str
772
+ The name of the OS.
773
+ shell_name: str
774
+ The name of the shell.
775
+ scheduler_name: str
776
+ The scheduler used.
777
+ running: bool
778
+ Whether the jobscript is currently running.
779
+ """
780
+
781
+ _EAR_files_delimiter: ClassVar[str] = ":"
782
+ _workflow_app_alias: ClassVar[str] = "wkflow_app"
783
+
784
+ _child_objects: ClassVar[tuple[ChildObjectSpec, ...]] = (
785
+ ChildObjectSpec(
786
+ name="resources",
787
+ class_name="ElementResources",
788
+ ),
789
+ ChildObjectSpec(
790
+ name="blocks",
791
+ class_name="JobscriptBlock",
792
+ is_multiple=True,
793
+ parent_ref="jobscript",
794
+ ),
795
+ )
796
+
797
+ def __init__(
798
+ self,
799
+ index: int,
800
+ is_array: bool,
801
+ resources: ElementResources,
802
+ blocks: list[JobscriptBlock],
803
+ at_submit_metadata: dict[str, Any] | None = None,
804
+ submit_hostname: str | None = None,
805
+ submit_machine: str | None = None,
806
+ shell_idx: int | None = None,
807
+ version_info: VersionInfo | None = None,
808
+ resource_hash: str | None = None,
809
+ elements: dict[int, list[int]] | None = None,
810
+ ):
811
+ if resource_hash is not None:
812
+ raise AttributeError("resource_hash must not be supplied")
813
+ if elements is not None:
814
+ raise AttributeError("elements must not be supplied")
815
+
816
+ if not isinstance(blocks[0], JobscriptBlock):
817
+ blocks = [
818
+ JobscriptBlock(**i, index=idx, jobscript=self)
819
+ for idx, i in enumerate(blocks)
820
+ ]
821
+
822
+ self._index = index
823
+ self._blocks = blocks
824
+ self._at_submit_metadata = at_submit_metadata or {
825
+ k: None for k in JOBSCRIPT_SUBMIT_TIME_KEYS
826
+ }
827
+ self._is_array = is_array
828
+ self._resources = resources
829
+
830
+ # assigned on parent `Submission.submit` (or retrieved form persistent store):
831
+ self._submit_hostname = submit_hostname
832
+ self._submit_machine = submit_machine
833
+ self._shell_idx = shell_idx
834
+
835
+ self._version_info = version_info
836
+
837
+ # assigned by parent Submission
838
+ self._submission: Submission | None = None
839
+ # assigned on first access to `scheduler` property
840
+ self._scheduler_obj: Scheduler | None = None
841
+ # assigned on first access to `shell` property
842
+ self._shell_obj: Shell | None = None
843
+ # assigned on first access to `submit_time` property
844
+ self._submit_time_obj: datetime | None = None
845
+ # assigned on first access to `all_EARs` property
846
+ self._all_EARs: list[ElementActionRun] | None = None
847
+
848
+ self._set_parent_refs()
849
+
850
+ def __repr__(self) -> str:
851
+ return (
852
+ f"{self.__class__.__name__}("
853
+ f"index={self.index!r}, "
854
+ f"blocks={self.blocks!r}, "
855
+ f"resources={self.resources!r}, "
856
+ f")"
857
+ )
858
+
859
+ @override
860
+ def _postprocess_to_dict(self, d: dict[str, Any]) -> dict[str, Any]:
861
+ dct = super()._postprocess_to_dict(d)
862
+ del dct["_scheduler_obj"]
863
+ del dct["_shell_obj"]
864
+ del dct["_submit_time_obj"]
865
+ del dct["_all_EARs"]
866
+ dct = {k.lstrip("_"): v for k, v in dct.items()}
867
+ return dct
868
+
869
+ @classmethod
870
+ def from_json_like(cls, json_like, shared_data=None):
871
+ return super().from_json_like(json_like, shared_data)
872
+
873
+ @property
874
+ def workflow_app_alias(self) -> str:
875
+ """
876
+ Alias for the workflow app in job scripts.
877
+ """
878
+ return self.submission.WORKFLOW_APP_ALIAS
879
+
880
+ def get_commands_file_name(
881
+ self, block_act_key: BlockActionKey, shell: Shell | None = None
882
+ ) -> str:
883
+ """
884
+ Get the name of a file containing commands for a particular jobscript action.
885
+ """
886
+ return self._app.RunDirAppFiles.get_commands_file_name(
887
+ block_act_key,
888
+ shell=shell or self.shell,
889
+ )
890
+
891
+ @property
892
+ def blocks(self) -> Sequence[JobscriptBlock]:
893
+ return self._blocks
894
+
895
+ @property
896
+ def at_submit_metadata(self) -> dict[str, Any]:
897
+ return self.workflow._store.get_jobscript_at_submit_metadata(
898
+ sub_idx=self.submission.index,
899
+ js_idx=self.index,
900
+ metadata_attr=self._at_submit_metadata,
901
+ )
902
+
903
+ @property
904
+ @TimeIt.decorator
905
+ def all_EAR_IDs(self) -> NDArray:
906
+ """Return all run IDs of this jobscripts (across all blocks), removing missing
907
+ run IDs (i.e. -1 values)"""
908
+ return np.concatenate([i.EAR_ID[i.EAR_ID >= 0] for i in self.blocks])
909
+
910
+ @property
911
+ @TimeIt.decorator
912
+ def all_EARs(self) -> Sequence[ElementActionRun]:
913
+ """
914
+ Description of EAR information for this jobscript.
915
+ """
916
+ if self.submission._use_EARs_cache:
917
+ return [self.submission._EARs_cache[ear_id] for ear_id in self.all_EAR_IDs]
918
+ return self.workflow.get_EARs_from_IDs(self.all_EAR_IDs)
919
+
920
+ @property
921
+ @TimeIt.decorator
922
+ def resources(self) -> ElementResources:
923
+ """
924
+ The common resources that this jobscript requires.
925
+ """
926
+ return self._resources
927
+
928
+ @property
929
+ @TimeIt.decorator
930
+ def dependencies(self) -> Mapping[tuple[int, int], dict[str, bool]]:
931
+ """
932
+ The dependency descriptor, accounting for all blocks within this jobscript.
933
+ """
934
+ deps = {}
935
+ for block in self.blocks:
936
+ for (js_idx, blk_idx), v in block.dependencies.items():
937
+ if js_idx == self.index:
938
+ # block dependency is internal to this jobscript
939
+ continue
940
+ else:
941
+ deps[js_idx, blk_idx] = {"is_array": v["is_array"]}
942
+ return deps
943
+
944
+ @property
945
+ @TimeIt.decorator
946
+ def start_time(self) -> None | datetime:
947
+ """The first known start time of any EAR in this jobscript."""
948
+ if not self.is_submitted:
949
+ return None
950
+ return min(
951
+ (ear.start_time for ear in self.all_EARs if ear.start_time), default=None
952
+ )
953
+
954
+ @property
955
+ @TimeIt.decorator
956
+ def end_time(self) -> None | datetime:
957
+ """The last known end time of any EAR in this jobscript."""
958
+ if not self.is_submitted:
959
+ return None
960
+ return max((ear.end_time for ear in self.all_EARs if ear.end_time), default=None)
961
+
962
+ @property
963
+ def submit_time(self):
964
+ """
965
+ When the jobscript was submitted, if known.
966
+ """
967
+ if self._submit_time_obj is None:
968
+ if _submit_time := self.at_submit_metadata["submit_time"]:
969
+ self._submit_time_obj = parse_timestamp(
970
+ _submit_time, self.workflow.ts_fmt
971
+ )
972
+ return self._submit_time_obj
973
+
974
+ @property
975
+ def submit_hostname(self) -> str | None:
976
+ """
977
+ Where the jobscript was submitted, if known.
978
+ """
979
+ return self._submit_hostname
980
+
981
+ @property
982
+ def submit_machine(self) -> str | None:
983
+ """
984
+ Description of what the jobscript was submitted to, if known.
985
+ """
986
+ return self._submit_machine
987
+
988
+ @property
989
+ def shell_idx(self):
990
+ return self._shell_idx
991
+
992
+ @property
993
+ def submit_cmdline(self) -> list[str] | None:
994
+ """
995
+ The command line used to submit the jobscript, if known.
996
+ """
997
+ return self.at_submit_metadata["submit_cmdline"]
998
+
999
+ @property
1000
+ def scheduler_job_ID(self) -> str | None:
1001
+ """
1002
+ The job ID from the scheduler, if known.
1003
+ """
1004
+ return self.at_submit_metadata["scheduler_job_ID"]
1005
+
1006
+ @property
1007
+ def process_ID(self) -> int | None:
1008
+ """
1009
+ The process ID from direct execution, if known.
1010
+ """
1011
+ return self.at_submit_metadata["process_ID"]
1012
+
1013
+ @property
1014
+ def version_info(self) -> VersionInfo | None:
1015
+ """
1016
+ Version information about the execution environment (OS, etc).
1017
+ """
1018
+ return self._version_info
1019
+
1020
+ @property
1021
+ def index(self) -> int:
1022
+ """
1023
+ The index of this jobscript within its parent :py:class:`Submission`.
1024
+ """
1025
+ assert self._index is not None
1026
+ return self._index
1027
+
1028
+ @property
1029
+ def submission(self) -> Submission:
1030
+ """
1031
+ The parent submission.
1032
+ """
1033
+ assert self._submission is not None
1034
+ return self._submission
1035
+
1036
+ @property
1037
+ def workflow(self) -> Workflow:
1038
+ """
1039
+ The workflow this is all on behalf of.
1040
+ """
1041
+ return self.submission.workflow
1042
+
1043
+ @property
1044
+ def is_array(self) -> bool:
1045
+ """
1046
+ Whether to generate an array job.
1047
+ """
1048
+ return self._is_array
1049
+
1050
+ @property
1051
+ def os_name(self) -> str:
1052
+ """
1053
+ The name of the OS to use.
1054
+ """
1055
+ assert self.resources.os_name
1056
+ return self.resources.os_name
1057
+
1058
+ @property
1059
+ def shell_name(self) -> str:
1060
+ assert self.resources.shell
1061
+ return self.resources.shell
1062
+
1063
+ @property
1064
+ def scheduler_name(self) -> str:
1065
+ """
1066
+ The name of the scheduler to use.
1067
+ """
1068
+ assert self.resources.scheduler
1069
+ return self.resources.scheduler
1070
+
1071
+ def _get_submission_os_args(self) -> dict[str, str]:
1072
+ return {"linux_release_file": self._app.config.linux_release_file}
1073
+
1074
+ def _get_submission_shell_args(self) -> dict[str, Any]:
1075
+ return self.resources.shell_args
1076
+
1077
+ def _get_submission_scheduler_args(self) -> dict[str, Any]:
1078
+ return self.resources.scheduler_args
1079
+
1080
+ def _get_shell(
1081
+ self,
1082
+ os_name: str,
1083
+ shell_name: str | None,
1084
+ os_args: dict[str, Any] | None = None,
1085
+ shell_args: dict[str, Any] | None = None,
1086
+ ) -> Shell:
1087
+ """Get an arbitrary shell, not necessarily associated with submission."""
1088
+ return get_shell(
1089
+ shell_name=shell_name,
1090
+ os_name=os_name,
1091
+ os_args=os_args or {},
1092
+ **(shell_args or {}),
1093
+ )
1094
+
1095
+ @property
1096
+ def shell(self) -> Shell:
1097
+ """The shell for composing submission scripts."""
1098
+ if self._shell_obj is None:
1099
+ self._shell_obj = self._get_shell(
1100
+ os_name=self.os_name,
1101
+ shell_name=self.shell_name,
1102
+ os_args=self._get_submission_os_args(),
1103
+ shell_args=self._get_submission_shell_args(),
1104
+ )
1105
+ return self._shell_obj
1106
+
1107
+ @property
1108
+ def scheduler(self) -> Scheduler:
1109
+ """The scheduler that submissions go to from this jobscript."""
1110
+ if self._scheduler_obj is None:
1111
+ assert self.scheduler_name
1112
+ self._scheduler_obj = self._app.get_scheduler(
1113
+ scheduler_name=self.scheduler_name,
1114
+ os_name=self.os_name,
1115
+ scheduler_args=self._get_submission_scheduler_args(),
1116
+ )
1117
+ return self._scheduler_obj
1118
+
1119
+ @property
1120
+ def EAR_ID_file_name(self) -> str:
1121
+ """
1122
+ The name of a file containing EAR IDs.
1123
+ """
1124
+ return f"js_{self.index}_EAR_IDs.txt"
1125
+
1126
+ @property
1127
+ def combined_script_indices_file_name(self) -> str:
1128
+ return f"js_{self.index}_script_indices.txt"
1129
+
1130
+ @property
1131
+ def direct_win_pid_file_name(self) -> str:
1132
+ """File for holding the direct execution PID."""
1133
+ return f"js_{self.index}_pid.txt"
1134
+
1135
+ @property
1136
+ def jobscript_name(self) -> str:
1137
+ """The name of the jobscript file."""
1138
+ return f"js_{self.index}{self.shell.JS_EXT}"
1139
+
1140
+ @property
1141
+ def jobscript_functions_name(self):
1142
+ assert self.shell_idx is not None
1143
+ return self.submission.get_jobscript_functions_name(self.shell, self.shell_idx)
1144
+
1145
+ @property
1146
+ def EAR_ID_file_path(self) -> Path:
1147
+ """
1148
+ The path to the file containing EAR IDs for this jobscript.
1149
+ """
1150
+ return self.submission.js_run_ids_path / self.EAR_ID_file_name
1151
+
1152
+ @property
1153
+ def combined_script_indices_file_path(self) -> Path:
1154
+ """
1155
+ The path to the file containing script indices, in the case this is a
1156
+ ``combine_scripts=True`` jobscript.
1157
+ """
1158
+ return (
1159
+ self.submission.js_script_indices_path
1160
+ / self.combined_script_indices_file_name
1161
+ )
1162
+
1163
+ @property
1164
+ def jobscript_path(self) -> Path:
1165
+ """
1166
+ The path to the file containing the jobscript file.
1167
+ """
1168
+ return self.submission.js_path / self.jobscript_name
1169
+
1170
+ @property
1171
+ def jobscript_functions_path(self) -> Path:
1172
+ """
1173
+ The path to the file containing the supporting shell functions."""
1174
+ assert self.shell_idx is not None
1175
+ return self.submission.get_jobscript_functions_path(self.shell, self.shell_idx)
1176
+
1177
+ @property
1178
+ def std_path(self) -> Path:
1179
+ """Directory in which to store jobscript standard out and error stream files."""
1180
+ return self.submission.js_std_path / str(self.index)
1181
+
1182
+ @property
1183
+ def direct_std_out_err_path(self) -> Path:
1184
+ """File path of combined standard output and error streams.
1185
+
1186
+ Notes
1187
+ -----
1188
+ This path will only exist if `resources.combine_jobscript_std` is True. Otherwise,
1189
+ see `direct_stdout_path` and `direct_stderr_path` for the separate stream paths.
1190
+
1191
+ """
1192
+ return self.get_std_out_err_path()
1193
+
1194
+ @property
1195
+ def direct_stdout_path(self) -> Path:
1196
+ """File path to which the jobscript's standard output is saved, for direct
1197
+ execution only.
1198
+
1199
+ Notes
1200
+ -----
1201
+ This returned path be the same as that from `get_stderr_path` if
1202
+ `resources.combine_jobscript_std` is True.
1203
+
1204
+ """
1205
+ assert not self.is_scheduled
1206
+ return self.get_stdout_path()
1207
+
1208
+ @property
1209
+ def direct_stderr_path(self) -> Path:
1210
+ """File path to which the jobscript's standard error is saved, for direct
1211
+ execution only.
1212
+
1213
+ Notes
1214
+ -----
1215
+ This returned path be the same as that from `get_stdout_path` if
1216
+ `resources.combine_jobscript_std` is True.
1217
+
1218
+ """
1219
+ assert not self.is_scheduled
1220
+ return self.get_stderr_path()
1221
+
1222
+ def __validate_get_std_path_array_idx(self, array_idx: int | None = None):
1223
+ if array_idx is None and self.is_array:
1224
+ raise ValueError(
1225
+ "`array_idx` must be specified, since this jobscript is an array job."
1226
+ )
1227
+ elif array_idx is not None and not self.is_array:
1228
+ raise ValueError(
1229
+ "`array_idx` should not be specified, since this jobscript is not an "
1230
+ "array job."
1231
+ )
1232
+
1233
+ def _get_stdout_path(self, array_idx: int | None = None) -> Path:
1234
+ """File path to the separate standard output stream.
1235
+
1236
+ Notes
1237
+ -----
1238
+ This path will only exist if `resources.combine_jobscript_std` is False.
1239
+ Otherwise, see `get_std_out_err_path` for the combined stream path.
1240
+
1241
+ """
1242
+ self.__validate_get_std_path_array_idx(array_idx)
1243
+ return self.std_path / self.scheduler.get_stdout_filename(
1244
+ js_idx=self.index, job_ID=self.scheduler_job_ID, array_idx=array_idx
1245
+ )
1246
+
1247
+ def _get_stderr_path(self, array_idx: int | None = None) -> Path:
1248
+ """File path to the separate standard error stream.
1249
+
1250
+ Notes
1251
+ -----
1252
+ This path will only exist if `resources.combine_jobscript_std` is False.
1253
+ Otherwise, see `get_std_out_err_path` for the combined stream path.
1254
+
1255
+ """
1256
+ self.__validate_get_std_path_array_idx(array_idx)
1257
+ return self.std_path / self.scheduler.get_stderr_filename(
1258
+ js_idx=self.index, job_ID=self.scheduler_job_ID, array_idx=array_idx
1259
+ )
1260
+
1261
+ def get_std_out_err_path(self, array_idx: int | None = None) -> Path:
1262
+ """File path of combined standard output and error streams.
1263
+
1264
+ Notes
1265
+ -----
1266
+ This path will only exist if `resources.combine_jobscript_std` is True. Otherwise,
1267
+ see `get_stdout_path` and `get_stderr_path` for the separate stream paths.
1268
+
1269
+ """
1270
+ self.__validate_get_std_path_array_idx(array_idx)
1271
+ return self.std_path / self.scheduler.get_std_out_err_filename(
1272
+ js_idx=self.index, job_ID=self.scheduler_job_ID, array_idx=array_idx
1273
+ )
1274
+
1275
+ def get_stdout_path(self, array_idx: int | None = None) -> Path:
1276
+ """File path to which the jobscript's standard output is saved.
1277
+
1278
+ Notes
1279
+ -----
1280
+ This returned path be the same as that from `get_stderr_path` if
1281
+ `resources.combine_jobscript_std` is True.
1282
+
1283
+ """
1284
+ if self.resources.combine_jobscript_std:
1285
+ return self.get_std_out_err_path(array_idx=array_idx)
1286
+ else:
1287
+ return self._get_stdout_path(array_idx=array_idx)
1288
+
1289
+ def get_stderr_path(self, array_idx: int | None = None) -> Path:
1290
+ """File path to which the jobscript's standard error is saved.
1291
+
1292
+ Notes
1293
+ -----
1294
+ This returned path be the same as that from `get_stdout_path` if
1295
+ `resources.combine_jobscript_std` is True.
1296
+
1297
+ """
1298
+ if self.resources.combine_jobscript_std:
1299
+ return self.get_std_out_err_path(array_idx=array_idx)
1300
+ else:
1301
+ return self._get_stderr_path(array_idx=array_idx)
1302
+
1303
+ def get_stdout(self, array_idx: int | None = None) -> str:
1304
+ """Retrieve the contents of the standard output stream file.
1305
+
1306
+ Notes
1307
+ -----
1308
+ In the case of non-array jobscripts, this will return the whole standard output,
1309
+ even if that includes multiple elements/actions.
1310
+
1311
+ """
1312
+ return self.workflow.get_text_file(self.get_stdout_path(array_idx))
1313
+
1314
+ def get_stderr(self, array_idx: int | None = None) -> str:
1315
+ """Retrieve the contents of the standard error stream file.
1316
+
1317
+ Notes
1318
+ -----
1319
+ In the case of non-array jobscripts, this will return the whole standard error,
1320
+ even if that includes multiple elements/actions.
1321
+
1322
+ """
1323
+ return self.workflow.get_text_file(self.get_stderr_path(array_idx))
1324
+
1325
+ def print_stdout(self, array_idx: int | None = None) -> None:
1326
+ """Print the contents of the standard output stream file.
1327
+
1328
+ Notes
1329
+ -----
1330
+ In the case of non-array jobscripts, this will print the whole standard output,
1331
+ even if that includes multiple elements/actions.
1332
+
1333
+ """
1334
+ print(self.get_stdout(array_idx))
1335
+
1336
+ def print_stderr(self, array_idx: int | None = None) -> None:
1337
+ """Print the contents of the standard error stream file.
1338
+
1339
+ Notes
1340
+ -----
1341
+ In the case of non-array jobscripts, this will print the whole standard error,
1342
+ even if that includes multiple elements/actions.
1343
+
1344
+ """
1345
+ print(self.get_stderr(array_idx))
1346
+
1347
+ @property
1348
+ def direct_win_pid_file_path(self) -> Path:
1349
+ """
1350
+ The path to the file containing PIDs for directly executed commands for this
1351
+ jobscript. Windows only.
1352
+ """
1353
+ return self.submission.js_win_pids_path / self.direct_win_pid_file_name
1354
+
1355
+ @property
1356
+ def is_scheduled(self) -> bool:
1357
+ return self.scheduler_name not in ("direct", "direct_posix")
1358
+
1359
+ def _update_at_submit_metadata(
1360
+ self,
1361
+ submit_cmdline: list[str] | None = None,
1362
+ scheduler_job_ID: str | None = None,
1363
+ process_ID: int | None = None,
1364
+ submit_time: str | None = None,
1365
+ ):
1366
+ """Update persistent store and in-memory record of at-submit metadata for this
1367
+ jobscript.
1368
+
1369
+ """
1370
+ self.workflow._store.set_jobscript_metadata(
1371
+ sub_idx=self.submission.index,
1372
+ js_idx=self.index,
1373
+ submit_cmdline=submit_cmdline,
1374
+ scheduler_job_ID=scheduler_job_ID,
1375
+ process_ID=process_ID,
1376
+ submit_time=submit_time,
1377
+ )
1378
+
1379
+ if submit_cmdline is not None:
1380
+ self._at_submit_metadata["submit_cmdline"] = submit_cmdline
1381
+ if scheduler_job_ID is not None:
1382
+ self._at_submit_metadata["scheduler_job_ID"] = scheduler_job_ID
1383
+ if process_ID is not None:
1384
+ self._at_submit_metadata["process_ID"] = process_ID
1385
+ if submit_time is not None:
1386
+ self._at_submit_metadata["submit_time"] = submit_time
1387
+
1388
+ def _set_submit_time(self, submit_time: datetime) -> None:
1389
+ self._update_at_submit_metadata(
1390
+ submit_time=submit_time.strftime(self.workflow.ts_fmt)
1391
+ )
1392
+
1393
+ def _set_submit_hostname(self, submit_hostname: str) -> None:
1394
+ self._submit_hostname = submit_hostname
1395
+ self.workflow._store.set_jobscript_metadata(
1396
+ sub_idx=self.submission.index,
1397
+ js_idx=self.index,
1398
+ submit_hostname=submit_hostname,
1399
+ )
1400
+
1401
+ def _set_submit_machine(self, submit_machine: str) -> None:
1402
+ self._submit_machine = submit_machine
1403
+ self.workflow._store.set_jobscript_metadata(
1404
+ sub_idx=self.submission.index,
1405
+ js_idx=self.index,
1406
+ submit_machine=submit_machine,
1407
+ )
1408
+
1409
+ def _set_shell_idx(self, shell_idx: int) -> None:
1410
+ self._shell_idx = shell_idx
1411
+ self.workflow._store.set_jobscript_metadata(
1412
+ sub_idx=self.submission.index,
1413
+ js_idx=self.index,
1414
+ shell_idx=shell_idx,
1415
+ )
1416
+
1417
+ def _set_submit_cmdline(self, submit_cmdline: list[str]) -> None:
1418
+ self._update_at_submit_metadata(submit_cmdline=submit_cmdline)
1419
+
1420
+ def _set_scheduler_job_ID(self, job_ID: str) -> None:
1421
+ """For scheduled submission only."""
1422
+ assert self.is_scheduled
1423
+ self._update_at_submit_metadata(scheduler_job_ID=job_ID)
1424
+
1425
+ def _set_process_ID(self, process_ID: int) -> None:
1426
+ """For direct submission only."""
1427
+ assert not self.is_scheduled
1428
+ self._update_at_submit_metadata(process_ID=process_ID)
1429
+
1430
+ def _set_version_info(self, version_info: VersionInfo) -> None:
1431
+ self._version_info = version_info
1432
+ self.workflow._store.set_jobscript_metadata(
1433
+ sub_idx=self.submission.index,
1434
+ js_idx=self.index,
1435
+ version_info=version_info,
1436
+ )
1437
+
1438
+ @TimeIt.decorator
1439
+ def compose_jobscript(
1440
+ self,
1441
+ shell,
1442
+ deps: dict[int, tuple[str, bool]] | None = None,
1443
+ os_name: str | None = None,
1444
+ scheduler_name: str | None = None,
1445
+ scheduler_args: dict[str, Any] | None = None,
1446
+ ) -> str:
1447
+ """Prepare the jobscript file contents as a string."""
1448
+ scheduler_name = scheduler_name or self.scheduler_name
1449
+ assert scheduler_name
1450
+ assert os_name
1451
+ scheduler = self._app.get_scheduler(
1452
+ scheduler_name=scheduler_name,
1453
+ os_name=os_name,
1454
+ scheduler_args=scheduler_args or self._get_submission_scheduler_args(),
1455
+ )
1456
+ app_caps = self._app.package_name.upper()
1457
+ header_args = {
1458
+ "app_caps": app_caps,
1459
+ "jobscript_functions_name": self.jobscript_functions_name,
1460
+ "jobscript_functions_dir": self.submission.JS_FUNCS_DIR_NAME,
1461
+ "sub_idx": self.submission.index,
1462
+ "js_idx": self.index,
1463
+ "run_IDs_file_name": self.EAR_ID_file_name,
1464
+ "run_IDs_file_dir": self.submission.JS_RUN_IDS_DIR_NAME,
1465
+ "tmp_dir_name": self.submission.TMP_DIR_NAME,
1466
+ "log_dir_name": self.submission.LOG_DIR_NAME,
1467
+ "app_std_dir_name": self.submission.APP_STD_DIR_NAME,
1468
+ "scripts_dir_name": self.submission.SCRIPTS_DIR_NAME,
1469
+ }
1470
+
1471
+ shebang = shell.JS_SHEBANG.format(
1472
+ shebang=" ".join(scheduler.shebang_executable or shell.shebang_executable)
1473
+ )
1474
+ header = shell.JS_HEADER.format(**header_args)
1475
+
1476
+ if isinstance(scheduler, QueuedScheduler):
1477
+ header = shell.JS_SCHEDULER_HEADER.format(
1478
+ shebang=shebang,
1479
+ scheduler_options=scheduler.format_directives(
1480
+ resources=self.resources,
1481
+ num_elements=self.blocks[0].num_elements, # only used for array jobs
1482
+ is_array=self.is_array,
1483
+ sub_idx=self.submission.index,
1484
+ js_idx=self.index,
1485
+ ),
1486
+ header=header,
1487
+ )
1488
+ else:
1489
+ # the Scheduler (direct submission)
1490
+ assert isinstance(scheduler, DirectScheduler)
1491
+ wait_cmd = shell.get_wait_command(
1492
+ workflow_app_alias=self.workflow_app_alias,
1493
+ sub_idx=self.submission.index,
1494
+ deps=deps or {},
1495
+ )
1496
+ header = shell.JS_DIRECT_HEADER.format(
1497
+ shebang=shebang,
1498
+ header=header,
1499
+ workflow_app_alias=self.workflow_app_alias,
1500
+ wait_command=wait_cmd,
1501
+ )
1502
+
1503
+ out = header
1504
+
1505
+ if self.resources.combine_scripts:
1506
+ run_cmd = shell.JS_RUN_CMD_COMBINED.format(
1507
+ workflow_app_alias=self.workflow_app_alias
1508
+ )
1509
+ out += run_cmd + "\n"
1510
+ else:
1511
+ run_cmd = shell.JS_RUN_CMD.format(workflow_app_alias=self.workflow_app_alias)
1512
+
1513
+ if self.resources.write_app_logs:
1514
+ run_log_enable_disable = shell.JS_RUN_LOG_PATH_ENABLE.format(
1515
+ run_log_file_name=self.submission.get_app_log_file_name(
1516
+ run_ID=shell.format_env_var_get(f"{app_caps}_RUN_ID")
1517
+ )
1518
+ )
1519
+ else:
1520
+ run_log_enable_disable = shell.JS_RUN_LOG_PATH_DISABLE
1521
+
1522
+ block_run = shell.JS_RUN.format(
1523
+ EAR_files_delimiter=self._EAR_files_delimiter,
1524
+ app_caps=app_caps,
1525
+ run_cmd=run_cmd,
1526
+ sub_tmp_dir=self.submission.tmp_path,
1527
+ run_log_enable_disable=run_log_enable_disable,
1528
+ )
1529
+ if len(self.blocks) == 1:
1530
+ # forgo element and action loops if not necessary:
1531
+ block = self.blocks[0]
1532
+ if block.num_actions > 1:
1533
+ block_act = shell.JS_ACT_MULTI.format(
1534
+ num_actions=block.num_actions,
1535
+ run_block=indent(block_run, shell.JS_INDENT),
1536
+ )
1537
+ else:
1538
+ block_act = shell.JS_ACT_SINGLE.format(run_block=block_run)
1539
+
1540
+ main = shell.JS_MAIN.format(
1541
+ action=block_act,
1542
+ app_caps=app_caps,
1543
+ block_start_elem_idx=0,
1544
+ )
1545
+
1546
+ out += shell.JS_BLOCK_HEADER.format(app_caps=app_caps)
1547
+ if self.is_array:
1548
+ if not isinstance(scheduler, QueuedScheduler):
1549
+ raise Exception("can only schedule arrays of jobs to a queue")
1550
+ out += shell.JS_ELEMENT_MULTI_ARRAY.format(
1551
+ scheduler_command=scheduler.js_cmd,
1552
+ scheduler_array_switch=scheduler.array_switch,
1553
+ scheduler_array_item_var=scheduler.array_item_var,
1554
+ num_elements=block.num_elements,
1555
+ main=main,
1556
+ )
1557
+ elif block.num_elements == 1:
1558
+ out += shell.JS_ELEMENT_SINGLE.format(
1559
+ block_start_elem_idx=0,
1560
+ main=main,
1561
+ )
1562
+ else:
1563
+ out += shell.JS_ELEMENT_MULTI_LOOP.format(
1564
+ block_start_elem_idx=0,
1565
+ num_elements=block.num_elements,
1566
+ main=indent(main, shell.JS_INDENT),
1567
+ )
1568
+
1569
+ else:
1570
+ # use a shell loop for blocks, so always write the inner element and action
1571
+ # loops:
1572
+ block_act = shell.JS_ACT_MULTI.format(
1573
+ num_actions=shell.format_array_get_item("num_actions", "$block_idx"),
1574
+ run_block=indent(block_run, shell.JS_INDENT),
1575
+ )
1576
+ main = shell.JS_MAIN.format(
1577
+ action=block_act,
1578
+ app_caps=app_caps,
1579
+ block_start_elem_idx="$block_start_elem_idx",
1580
+ )
1581
+
1582
+ # only non-array jobscripts will have multiple blocks:
1583
+ element_loop = shell.JS_ELEMENT_MULTI_LOOP.format(
1584
+ block_start_elem_idx="$block_start_elem_idx",
1585
+ num_elements=shell.format_array_get_item(
1586
+ "num_elements", "$block_idx"
1587
+ ),
1588
+ main=indent(main, shell.JS_INDENT),
1589
+ )
1590
+ out += shell.JS_BLOCK_LOOP.format(
1591
+ num_elements=shell.format_array(
1592
+ [i.num_elements for i in self.blocks]
1593
+ ),
1594
+ num_actions=shell.format_array([i.num_actions for i in self.blocks]),
1595
+ num_blocks=len(self.blocks),
1596
+ app_caps=app_caps,
1597
+ element_loop=indent(element_loop, shell.JS_INDENT),
1598
+ )
1599
+
1600
+ out += shell.JS_FOOTER
1601
+
1602
+ return out
1603
+
1604
+ @TimeIt.decorator
1605
+ def write_jobscript(
1606
+ self,
1607
+ os_name: str | None = None,
1608
+ shell_name: str | None = None,
1609
+ deps: dict[int, tuple[str, bool]] | None = None,
1610
+ os_args: dict[str, Any] | None = None,
1611
+ shell_args: dict[str, Any] | None = None,
1612
+ scheduler_name: str | None = None,
1613
+ scheduler_args: dict[str, Any] | None = None,
1614
+ ) -> Path:
1615
+ """
1616
+ Write the jobscript to its file.
1617
+ """
1618
+ os_name = os_name or self.os_name
1619
+ shell_name = shell_name or self.shell_name
1620
+ assert os_name
1621
+ assert shell_name
1622
+ shell = self._get_shell(
1623
+ os_name=os_name,
1624
+ shell_name=shell_name,
1625
+ os_args=os_args or self._get_submission_os_args(),
1626
+ shell_args=shell_args or self._get_submission_shell_args(),
1627
+ )
1628
+
1629
+ js_str = self.compose_jobscript(
1630
+ deps=deps,
1631
+ shell=shell,
1632
+ os_name=os_name,
1633
+ scheduler_name=scheduler_name,
1634
+ scheduler_args=scheduler_args,
1635
+ )
1636
+ with self.jobscript_path.open("wt", newline="\n") as fp:
1637
+ fp.write(js_str)
1638
+
1639
+ return self.jobscript_path
1640
+
1641
+ @TimeIt.decorator
1642
+ def _launch_direct_js_win(self, submit_cmd: list[str]) -> int:
1643
+ # this is a "trick" to ensure we always get a fully detached new process (with no
1644
+ # parent); the `powershell.exe -Command` process exits after running the inner
1645
+ # `Start-Process`, which is where the jobscript is actually invoked. I could not
1646
+ # find a way using `subprocess.Popen()` to ensure the new process was fully
1647
+ # detached when submitting jobscripts via a Jupyter notebook in Windows.
1648
+
1649
+ # Note we need powershell.exe for this "launcher process", but the shell used for
1650
+ # the jobscript itself need not be powershell.exe
1651
+ exe_path, arg_list = submit_cmd[0], submit_cmd[1:]
1652
+
1653
+ # note powershell-escaped quotes, in case of spaces in arguments (this seems to
1654
+ # work okay even though we might have switch like arguments in this list, like
1655
+ # "-File"):
1656
+ arg_list_str = ",".join(f'"`"{i}`""' for i in arg_list)
1657
+
1658
+ args = [
1659
+ "powershell.exe",
1660
+ "-Command",
1661
+ f"$JS_proc = Start-Process "
1662
+ f'-Passthru -NoNewWindow -FilePath "{exe_path}" '
1663
+ f'-RedirectStandardOutput "{self.direct_stdout_path}" '
1664
+ f'-RedirectStandardError "{self.direct_stderr_path}" '
1665
+ f'-WorkingDirectory "{self.workflow.path}" '
1666
+ f"-ArgumentList {arg_list_str}; "
1667
+ f'Set-Content -Path "{self.direct_win_pid_file_path}" -Value $JS_proc.Id',
1668
+ ]
1669
+
1670
+ self._app.submission_logger.info(
1671
+ f"running direct Windows jobscript launcher process: {args!r}"
1672
+ )
1673
+ # for some reason we still need to create a "detached" process here as well:
1674
+ init_proc = subprocess.Popen(
1675
+ args=args,
1676
+ cwd=self.workflow.path,
1677
+ creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
1678
+ )
1679
+ init_proc.wait() # wait for the process ID file to be written
1680
+ return int(self.direct_win_pid_file_path.read_text())
1681
+
1682
+ @TimeIt.decorator
1683
+ def _launch_direct_js_posix(self, submit_cmd: list[str]) -> int:
1684
+ # direct submission; submit jobscript asynchronously:
1685
+ # detached process, avoid interrupt signals propagating to the subprocess:
1686
+
1687
+ def _launch(fp_stdout: TextIO, fp_stderr: TextIO) -> int:
1688
+ # note: Popen copies the file objects, so this works!
1689
+ proc = subprocess.Popen(
1690
+ args=submit_cmd,
1691
+ stdout=fp_stdout,
1692
+ stderr=fp_stderr,
1693
+ cwd=str(self.workflow.path),
1694
+ start_new_session=True,
1695
+ )
1696
+ return proc.pid
1697
+
1698
+ if self.resources.combine_jobscript_std:
1699
+ with self.direct_std_out_err_path.open("wt") as fp_std:
1700
+ return _launch(fp_std, fp_std)
1701
+ else:
1702
+ with self.direct_stdout_path.open(
1703
+ "wt"
1704
+ ) as fp_stdout, self.direct_stderr_path.open("wt") as fp_stderr:
1705
+ return _launch(fp_stdout, fp_stderr)
1706
+
1707
+ @TimeIt.decorator
1708
+ def _launch_queued(
1709
+ self, submit_cmd: list[str], print_stdout: bool
1710
+ ) -> tuple[str, str]:
1711
+ # scheduled submission, wait for submission so we can parse the job ID:
1712
+ proc = subprocess.run(
1713
+ args=submit_cmd,
1714
+ stdout=subprocess.PIPE,
1715
+ stderr=subprocess.PIPE,
1716
+ cwd=self.workflow.path,
1717
+ )
1718
+ stdout = proc.stdout.decode().strip()
1719
+ stderr = proc.stderr.decode().strip()
1720
+ if print_stdout and stdout:
1721
+ print(stdout)
1722
+ if stderr:
1723
+ print(stderr)
1724
+ return stdout, stderr
1725
+
1726
+ @TimeIt.decorator
1727
+ def submit(
1728
+ self,
1729
+ scheduler_refs: dict[int, tuple[str, bool]],
1730
+ print_stdout: bool = False,
1731
+ ) -> str:
1732
+ """
1733
+ Submit the jobscript to the scheduler.
1734
+ """
1735
+ # map each dependency jobscript index to the JS ref (job/process ID) and if the
1736
+ # dependency is an array dependency:
1737
+ deps: dict[int, tuple[str, bool]] = {}
1738
+ for (js_idx, _), deps_i in self.dependencies.items():
1739
+ dep_js_ref, dep_js_is_arr = scheduler_refs[js_idx]
1740
+ # only submit an array dependency if both this jobscript and the dependency
1741
+ # are array jobs:
1742
+ dep_is_arr = deps_i["is_array"] and self.is_array and dep_js_is_arr
1743
+ deps[js_idx] = (dep_js_ref, dep_is_arr)
1744
+
1745
+ if self.index > 0:
1746
+ # prevent this jobscript executing if jobscript parallelism is not available:
1747
+ use_parallelism = (
1748
+ self.submission.JS_parallelism is True
1749
+ or {0: "direct", 1: "scheduled"}[self.is_scheduled]
1750
+ == self.submission.JS_parallelism
1751
+ )
1752
+ if not use_parallelism:
1753
+ # add fake dependencies to all previously submitted jobscripts to avoid
1754
+ # simultaneous execution:
1755
+ for js_idx, (js_ref, _) in scheduler_refs.items():
1756
+ if js_idx not in deps:
1757
+ deps[js_idx] = (js_ref, False)
1758
+
1759
+ # make directory for jobscripts stdout/err stream files:
1760
+ self.std_path.mkdir(exist_ok=True)
1761
+
1762
+ with self.EAR_ID_file_path.open(mode="wt", newline="\n") as ID_fp:
1763
+ for block in self.blocks:
1764
+ block.write_EAR_ID_file(ID_fp)
1765
+
1766
+ js_path = self.shell.prepare_JS_path(self.write_jobscript(deps=deps))
1767
+ submit_cmd = self.scheduler.get_submit_command(self.shell, js_path, deps)
1768
+ self._app.submission_logger.info(
1769
+ f"submitting jobscript {self.index!r} with command: {submit_cmd!r}"
1770
+ )
1771
+
1772
+ err_args: JobscriptSubmissionFailureArgs = {
1773
+ "submit_cmd": submit_cmd,
1774
+ "js_idx": self.index,
1775
+ "js_path": js_path,
1776
+ }
1777
+ job_ID: str | None = None
1778
+ process_ID: int | None = None
1779
+ try:
1780
+ if isinstance(self.scheduler, QueuedScheduler):
1781
+ # scheduled submission, wait for submission so we can parse the job ID:
1782
+ stdout, stderr = self._launch_queued(submit_cmd, print_stdout)
1783
+ err_args["stdout"] = stdout
1784
+ err_args["stderr"] = stderr
1785
+ else:
1786
+ if os.name == "nt":
1787
+ process_ID = self._launch_direct_js_win(submit_cmd)
1788
+ else:
1789
+ process_ID = self._launch_direct_js_posix(submit_cmd)
1790
+ except Exception as subprocess_exc:
1791
+ err_args["subprocess_exc"] = subprocess_exc
1792
+ raise JobscriptSubmissionFailure(
1793
+ "Failed to execute submit command.", **err_args
1794
+ )
1795
+
1796
+ if isinstance(self.scheduler, QueuedScheduler):
1797
+ # scheduled submission
1798
+ if stderr:
1799
+ raise JobscriptSubmissionFailure(
1800
+ "Non-empty stderr from submit command.", **err_args
1801
+ )
1802
+
1803
+ try:
1804
+ job_ID = self.scheduler.parse_submission_output(stdout)
1805
+ assert job_ID is not None
1806
+ except Exception as job_ID_parse_exc:
1807
+ # TODO: maybe handle this differently. If there is no stderr, then the job
1808
+ # probably did submit fine, but the issue is just with parsing the job ID
1809
+ # (e.g. if the scheduler version was updated and it now outputs
1810
+ # differently).
1811
+ err_args["job_ID_parse_exc"] = job_ID_parse_exc
1812
+ raise JobscriptSubmissionFailure(
1813
+ "Failed to parse job ID from stdout.", **err_args
1814
+ )
1815
+
1816
+ self._set_scheduler_job_ID(job_ID)
1817
+ ref = job_ID
1818
+
1819
+ else:
1820
+ # direct submission
1821
+ assert process_ID is not None
1822
+ self._set_process_ID(process_ID)
1823
+ ref = str(process_ID)
1824
+
1825
+ self._set_submit_cmdline(submit_cmd)
1826
+ self._set_submit_time(current_timestamp())
1827
+
1828
+ # a downstream direct jobscript might need to wait for this jobscript, which
1829
+ # means this jobscript's process ID must be committed:
1830
+ self.workflow._store._pending.commit_all()
1831
+
1832
+ return ref
1833
+
1834
+ @property
1835
+ def is_submitted(self) -> bool:
1836
+ """Whether this jobscript has been submitted."""
1837
+ return self.index in self.submission.submitted_jobscripts
1838
+
1839
+ @property
1840
+ def scheduler_js_ref(self) -> str | None | tuple[int | None, list[str] | None]:
1841
+ """
1842
+ The reference to the submitted job for the jobscript.
1843
+ """
1844
+ if isinstance(self.scheduler, QueuedScheduler):
1845
+ return self.scheduler_job_ID
1846
+ else:
1847
+ return (self.process_ID, self.submit_cmdline)
1848
+
1849
+ @overload
1850
+ def get_active_states(
1851
+ self, as_json: Literal[False] = False
1852
+ ) -> Mapping[int, Mapping[int, JobscriptElementState]]: ...
1853
+
1854
+ @overload
1855
+ def get_active_states(
1856
+ self, as_json: Literal[True]
1857
+ ) -> Mapping[int, Mapping[int, str]]: ...
1858
+
1859
+ @TimeIt.decorator
1860
+ def get_active_states(
1861
+ self, as_json: bool = False
1862
+ ) -> Mapping[int, Mapping[int, JobscriptElementState | str]]:
1863
+ """If this jobscript is active on this machine, return the state information from
1864
+ the scheduler."""
1865
+ # this returns: {BLOCK_IDX: {JS_ELEMENT_IDX: STATE}}
1866
+ out: Mapping[int, Mapping[int, JobscriptElementState]] = {}
1867
+ if self.is_submitted:
1868
+ self._app.submission_logger.debug(
1869
+ "checking if the jobscript is running according to EAR submission "
1870
+ "states."
1871
+ )
1872
+
1873
+ not_run_states = EARStatus.get_non_running_submitted_states()
1874
+ all_EAR_states = set(ear.status for ear in self.all_EARs)
1875
+ self._app.submission_logger.debug(
1876
+ f"Unique EAR states are: {tuple(i.name for i in all_EAR_states)!r}"
1877
+ )
1878
+ if all_EAR_states.issubset(not_run_states):
1879
+ self._app.submission_logger.debug(
1880
+ "All jobscript EARs are in a non-running state"
1881
+ )
1882
+
1883
+ elif self._app.config.get("machine") == self.submit_machine:
1884
+ self._app.submission_logger.debug(
1885
+ "Checking if jobscript is running according to the scheduler/process "
1886
+ "ID."
1887
+ )
1888
+ out_d = self.scheduler.get_job_state_info(js_refs=[self.scheduler_js_ref])
1889
+ if out_d:
1890
+ # remove scheduler ref (should be only one):
1891
+ assert len(out_d) == 1
1892
+ out_i = nth_value(cast("dict", out_d), 0)
1893
+
1894
+ if self.is_array:
1895
+ # out_i is a dict keyed by array index; there will be exactly one
1896
+ # block:
1897
+ out = {0: out_i}
1898
+ else:
1899
+ # out_i is a single state:
1900
+ out = {
1901
+ idx: {i: out_i for i in range(block.num_elements)}
1902
+ for idx, block in enumerate(self.blocks)
1903
+ }
1904
+
1905
+ else:
1906
+ raise NotSubmitMachineError()
1907
+
1908
+ self._app.submission_logger.info(f"Jobscript is {'in' if not out else ''}active.")
1909
+ if as_json:
1910
+ return {
1911
+ block_idx: {k: v.name for k, v in block_data.items()}
1912
+ for block_idx, block_data in out.items()
1913
+ }
1914
+ return out
1915
+
1916
+ def compose_combined_script(
1917
+ self, action_scripts: list[list[tuple[str, Path, bool]]]
1918
+ ) -> tuple[str, list[list[int]], list[int], list[int]]:
1919
+ """
1920
+ Prepare the combined-script file string, if applicable.
1921
+ """
1922
+
1923
+ # use an index array for action scripts:
1924
+ script_names: list[str] = []
1925
+ requires_dir: list[bool] = []
1926
+ script_data: dict[str, tuple[int, Path]] = {}
1927
+ script_indices: list[list[int]] = []
1928
+ for i in action_scripts:
1929
+ indices_i: list[int] = []
1930
+ for name_j, path_j, req_dir_i in i:
1931
+ if name_j in script_data:
1932
+ idx = script_data[name_j][0]
1933
+ else:
1934
+ idx = len(script_names)
1935
+ script_names.append(name_j)
1936
+ requires_dir.append(req_dir_i)
1937
+ script_data[name_j] = (idx, path_j)
1938
+ indices_i.append(idx)
1939
+ script_indices.append(indices_i)
1940
+
1941
+ if not self.resources.combine_scripts:
1942
+ raise TypeError(
1943
+ f"Jobscript {self.index} is not a `combine_scripts` jobscript."
1944
+ )
1945
+
1946
+ tab_indent = " "
1947
+
1948
+ script_funcs_lst: list[str] = []
1949
+ future_imports: set[str] = set()
1950
+ for act_name, (_, snip_path) in script_data.items():
1951
+ main_func_name = snip_path.stem
1952
+ with snip_path.open("rt") as fp:
1953
+ script_str = fp.read()
1954
+ script_str, future_imports_i = extract_py_from_future_imports(script_str)
1955
+ future_imports.update(future_imports_i)
1956
+ script_funcs_lst.append(
1957
+ dedent(
1958
+ """\
1959
+ def {act_name}(*args, **kwargs):
1960
+ {script_str}
1961
+ return {main_func_name}(*args, **kwargs)
1962
+ """
1963
+ ).format(
1964
+ act_name=act_name,
1965
+ script_str=indent(script_str, tab_indent),
1966
+ main_func_name=main_func_name,
1967
+ )
1968
+ )
1969
+
1970
+ app_caps = self._app.package_name.upper()
1971
+ if self.resources.write_app_logs:
1972
+ sub_log_path = f'os.environ["{app_caps}_LOG_PATH"]'
1973
+ else:
1974
+ sub_log_path = '""'
1975
+
1976
+ py_imports = dedent(
1977
+ """\
1978
+ import os
1979
+ from collections import defaultdict
1980
+ from pathlib import Path
1981
+ import traceback
1982
+ import time
1983
+ from typing import Dict
1984
+
1985
+ import {app_module} as app
1986
+
1987
+ from hpcflow.sdk.core.errors import UnsetParameterDataErrorBase
1988
+
1989
+ log_path = {log_path}
1990
+ wk_path = os.getenv("{app_caps}_WK_PATH")
1991
+ """
1992
+ ).format(
1993
+ app_module=self._app.module,
1994
+ app_caps=app_caps,
1995
+ log_path=sub_log_path,
1996
+ )
1997
+
1998
+ py_main_block_workflow_load = dedent(
1999
+ """\
2000
+ app.load_config(
2001
+ log_file_path=log_path,
2002
+ config_dir=r"{cfg_dir}",
2003
+ config_key=r"{cfg_invoc_key}",
2004
+ )
2005
+ wk = app.Workflow(wk_path)
2006
+ """
2007
+ ).format(
2008
+ cfg_dir=self._app.config.config_directory,
2009
+ cfg_invoc_key=self._app.config.config_key,
2010
+ app_caps=app_caps,
2011
+ )
2012
+
2013
+ func_invoc_lines = dedent(
2014
+ """\
2015
+ import pprint
2016
+ if not run.action.is_OFP and run.action.script_data_out_has_direct:
2017
+ outputs = func(**func_kwargs)
2018
+ elif run.action.is_OFP:
2019
+ out_name = run.action.output_file_parsers[0].output.typ
2020
+ outputs = {out_name: func(**func_kwargs)}
2021
+ else:
2022
+ outputs = {}
2023
+ func(**func_kwargs)
2024
+ """
2025
+ )
2026
+
2027
+ script_funcs = "\n".join(script_funcs_lst)
2028
+ script_names_str = "[" + ", ".join(f"{i}" for i in script_names) + "]"
2029
+ main = dedent(
2030
+ """\
2031
+ {py_imports}
2032
+
2033
+ sub_std_path = Path(os.environ["{app_caps}_SUB_STD_DIR"], f"js_{js_idx}.txt")
2034
+ with app.redirect_std_to_file(sub_std_path):
2035
+ {py_main_block_workflow_load}
2036
+
2037
+ with open(os.environ["{app_caps}_RUN_ID_FILE"], mode="r") as fp:
2038
+ lns = fp.read().strip().split("\\n")
2039
+ run_IDs = [[int(i) for i in ln.split("{run_ID_delim}")] for ln in lns]
2040
+
2041
+ get_all_runs_tic = time.perf_counter()
2042
+ run_IDs_flat = [j for i in run_IDs for j in i]
2043
+ runs = wk.get_EARs_from_IDs(run_IDs_flat, as_dict=True)
2044
+ run_skips : Dict[int, bool] = {{k: v.skip for k, v in runs.items()}}
2045
+ get_all_runs_toc = time.perf_counter()
2046
+
2047
+ with open(os.environ["{app_caps}_SCRIPT_INDICES_FILE"], mode="r") as fp:
2048
+ lns = fp.read().strip().split("\\n")
2049
+ section_idx = -1
2050
+ script_indices = []
2051
+ for ln in lns:
2052
+ if ln.startswith("#"):
2053
+ section_idx += 1
2054
+ continue
2055
+ ln_parsed = [int(i) for i in ln.split("{script_idx_delim}")]
2056
+ if section_idx == 0:
2057
+ num_elements = ln_parsed
2058
+ elif section_idx == 1:
2059
+ num_actions = ln_parsed
2060
+ else:
2061
+ script_indices.append(ln_parsed)
2062
+
2063
+ port = int(os.environ["{app_caps}_RUN_PORT"])
2064
+ action_scripts = {script_names}
2065
+ requires_dir = {requires_dir!r}
2066
+ run_dirs = wk.get_run_directories()
2067
+
2068
+ get_ins_time_fp = open(f"js_{js_idx}_get_inputs_times.txt", "wt")
2069
+ func_time_fp = open(f"js_{js_idx}_func_times.txt", "wt")
2070
+ run_time_fp = open(f"js_{js_idx}_run_times.txt", "wt")
2071
+ set_start_multi_times_fp = open(f"js_{js_idx}_set_start_multi_times.txt", "wt")
2072
+ set_end_multi_times_fp = open(f"js_{js_idx}_set_end_multi_times.txt", "wt")
2073
+ save_multi_times_fp = open(f"js_{js_idx}_save_multi_times.txt", "wt")
2074
+ loop_term_times_fp = open(f"js_{js_idx}_loop_term_times.txt", "wt")
2075
+
2076
+ get_all_runs_time = get_all_runs_toc - get_all_runs_tic
2077
+ print(f"get_all_runs_time: {{get_all_runs_time:.4f}}")
2078
+
2079
+ app.logger.info(
2080
+ f"running {num_blocks} jobscript block(s) in combined jobscript index "
2081
+ f"{js_idx}."
2082
+ )
2083
+
2084
+ block_start_elem_idx = 0
2085
+ for block_idx in range({num_blocks}):
2086
+
2087
+ app.logger.info(f"running block index {{block_idx}}.")
2088
+
2089
+ os.environ["{app_caps}_BLOCK_IDX"] = str(block_idx)
2090
+
2091
+ block_run_IDs = [
2092
+ run_IDs[block_start_elem_idx + i]
2093
+ for i in range(num_elements[block_idx])
2094
+ ]
2095
+
2096
+ for block_act_idx in range(num_actions[block_idx]):
2097
+
2098
+ app.logger.info(
2099
+ f"running block action index {{block_act_idx}} "
2100
+ f"(in block {{block_idx}})."
2101
+ )
2102
+
2103
+ os.environ["{app_caps}_BLOCK_ACT_IDX"] = str(block_act_idx)
2104
+
2105
+ block_act_run_IDs = [i[block_act_idx] for i in block_run_IDs]
2106
+
2107
+ block_act_std_path = Path(
2108
+ os.environ["{app_caps}_SUB_STD_DIR"],
2109
+ f"js_{js_idx}_blk_{{block_idx}}_blk_act_{{block_act_idx}}.txt",
2110
+ )
2111
+ with app.redirect_std_to_file(block_act_std_path):
2112
+ # set run starts for all runs of the block/action:
2113
+ block_act_run_dirs = [run_dirs[i] for i in block_act_run_IDs]
2114
+ block_act_runs = [runs[i] for i in block_act_run_IDs]
2115
+
2116
+ block_act_run_IDs_non_skipped = []
2117
+ block_act_run_dirs_non_skipped = []
2118
+ for i, j in zip(block_act_run_IDs, block_act_run_dirs):
2119
+ if not run_skips[i]:
2120
+ block_act_run_IDs_non_skipped.append(i)
2121
+ block_act_run_dirs_non_skipped.append(j)
2122
+
2123
+ if block_act_run_IDs_non_skipped:
2124
+ set_start_multi_tic = time.perf_counter()
2125
+ app.logger.info("setting run starts.")
2126
+ wk.set_multi_run_starts(block_act_run_IDs_non_skipped, block_act_run_dirs_non_skipped, port)
2127
+ app.logger.info("finished setting run starts.")
2128
+ set_start_multi_toc = time.perf_counter()
2129
+ set_start_multi_time = set_start_multi_toc - set_start_multi_tic
2130
+ print(f"{{set_start_multi_time:.4f}}", file=set_start_multi_times_fp, flush=True)
2131
+
2132
+ all_act_outputs = {{}}
2133
+ run_end_dat = defaultdict(list)
2134
+ block_act_key=({js_idx}, block_idx, block_act_idx)
2135
+
2136
+ for block_elem_idx in range(num_elements[block_idx]):
2137
+
2138
+ js_elem_idx = block_start_elem_idx + block_elem_idx
2139
+ run_ID = block_act_run_IDs[block_elem_idx]
2140
+
2141
+ app.logger.info(
2142
+ f"run_ID is {{run_ID}}; block element index: {{block_elem_idx}}; "
2143
+ f"block action index: {{block_act_idx}}; in block {{block_idx}}."
2144
+ )
2145
+
2146
+ if run_ID == -1:
2147
+ continue
2148
+
2149
+ run = runs[run_ID]
2150
+
2151
+ skip = run_skips[run_ID]
2152
+ if skip:
2153
+ app.logger.info(f"run_ID: {{run_ID}}; run is set to skip; skipping.")
2154
+ # set run end
2155
+ run_end_dat[block_act_key].append((run, {skipped_exit_code}, None))
2156
+ continue
2157
+
2158
+ run_tic = time.perf_counter()
2159
+
2160
+ os.environ["{app_caps}_BLOCK_ELEM_IDX"] = str(block_elem_idx)
2161
+ os.environ["{app_caps}_JS_ELEM_IDX"] = str(js_elem_idx)
2162
+ os.environ["{app_caps}_RUN_ID"] = str(run_ID)
2163
+
2164
+ std_path = Path(os.environ["{app_caps}_SUB_STD_DIR"], f"{{run_ID}}.txt")
2165
+ with app.redirect_std_to_file(std_path):
2166
+
2167
+ if {write_app_logs!r}:
2168
+ new_log_path = Path(
2169
+ os.environ["{app_caps}_SUB_LOG_DIR"],
2170
+ f"{run_log_name}",
2171
+ )
2172
+ # TODO: this doesn't work!
2173
+ app.logger.info(
2174
+ f"run_ID: {{run_ID}}; moving log path to {{new_log_path}}"
2175
+ )
2176
+ app.config.log_path = new_log_path
2177
+
2178
+ run_dir = run_dirs[run_ID]
2179
+
2180
+ script_idx = script_indices[block_idx][block_act_idx]
2181
+ req_dir = requires_dir[script_idx]
2182
+ if req_dir:
2183
+ app.logger.info(f"run_ID: {{run_ID}}; changing to run directory: {{run_dir}}")
2184
+ os.chdir(run_dir)
2185
+
2186
+ # retrieve script inputs:
2187
+ app.logger.info(f"run_ID: {{run_ID}}; retrieving script inputs.")
2188
+ get_ins_tic = time.perf_counter()
2189
+ try:
2190
+ with run.raise_on_failure_threshold() as unset_params:
2191
+ app.logger.info(f"run_ID: {{run_ID}}; writing script input files.")
2192
+ run.write_script_data_in_files(block_act_key)
2193
+
2194
+ app.logger.info(f"run_ID: {{run_ID}}; retrieving funcion kwargs.")
2195
+ func_kwargs = run.get_py_script_func_kwargs(
2196
+ raise_on_unset=False,
2197
+ add_script_files=True,
2198
+ blk_act_key=block_act_key,
2199
+ )
2200
+ app.logger.info(
2201
+ f"run_ID: {{run_ID}}; script inputs have keys: "
2202
+ f"{{tuple(func_kwargs.keys())!r}}."
2203
+ )
2204
+ except UnsetParameterDataErrorBase:
2205
+ # not all required parameter data is set, so fail this run:
2206
+ exit_code = 1
2207
+ run_end_dat[block_act_key].append((run, exit_code, None))
2208
+ app.logger.info(
2209
+ f"run_ID: {{run_ID}}; some parameter data is unset, "
2210
+ f"so cannot run; setting exit code to 1."
2211
+ )
2212
+ continue # don't run the function
2213
+
2214
+ get_ins_toc = time.perf_counter()
2215
+
2216
+ func = action_scripts[script_idx]
2217
+ app.logger.info(f"run_ID: {{run_ID}}; function to run is: {{func.__name__}}")
2218
+
2219
+
2220
+ try:
2221
+ func_tic = time.perf_counter()
2222
+ app.logger.info(f"run_ID: {{run_ID}}; invoking function.")
2223
+ {func_invoc_lines}
2224
+
2225
+ except Exception:
2226
+ print(f"Exception caught during execution of script function {{func.__name__}}.")
2227
+ traceback.print_exc()
2228
+ exit_code = 1
2229
+ outputs = {{}}
2230
+ else:
2231
+ app.logger.info(f"run_ID: {{run_ID}}; finished function invocation.")
2232
+ exit_code = 0
2233
+ finally:
2234
+ func_toc = time.perf_counter()
2235
+
2236
+ with app.redirect_std_to_file(std_path):
2237
+ # set run end
2238
+ block_act_key=({js_idx}, block_idx, block_act_idx)
2239
+ run_end_dat[block_act_key].append((run, exit_code, run_dir))
2240
+
2241
+ # store outputs to save at end:
2242
+ app.logger.info(f"run_ID: {{run_ID}}; setting outputs to save.")
2243
+ for name_i, out_i in outputs.items():
2244
+ p_id = run.data_idx[f"outputs.{{name_i}}"]
2245
+ all_act_outputs[p_id] = out_i
2246
+ app.logger.info(f"run_ID: {{run_ID}}; finished setting outputs to save.")
2247
+
2248
+ if req_dir:
2249
+ app.logger.info(f"run_ID: {{run_ID}}; changing directory back")
2250
+ os.chdir(os.environ["{app_caps}_SUB_TMP_DIR"])
2251
+
2252
+ if {write_app_logs!r}:
2253
+ app.logger.info(f"run_ID: {{run_ID}}; moving log path back to " + {sub_log_path!r})
2254
+ app.config.log_path = {sub_log_path}
2255
+
2256
+ run_toc = time.perf_counter()
2257
+
2258
+ get_ins_time = get_ins_toc - get_ins_tic
2259
+ func_time = func_toc - func_tic
2260
+ run_time = run_toc - run_tic
2261
+
2262
+ print(f"{{get_ins_time:.4f}}", file=get_ins_time_fp)
2263
+ print(f"{{func_time:.4f}}", file=func_time_fp)
2264
+ print(f"{{run_time:.4f}}", file=run_time_fp)
2265
+
2266
+ with app.redirect_std_to_file(block_act_std_path):
2267
+
2268
+ if all_act_outputs:
2269
+ # save outputs of all elements of this action
2270
+ save_all_tic = time.perf_counter()
2271
+ app.logger.info(
2272
+ f"saving outputs of block action index {{block_act_idx}} "
2273
+ f"in block {{block_idx}}."
2274
+ )
2275
+ wk.set_parameter_values(all_act_outputs)
2276
+ app.logger.info(
2277
+ f"finished saving outputs of block action index {{block_act_idx}} "
2278
+ f"in block {{block_idx}}."
2279
+ )
2280
+ save_all_toc = time.perf_counter()
2281
+ save_all_time_i = save_all_toc - save_all_tic
2282
+ print(f"{{save_all_time_i:.4f}}", file=save_multi_times_fp, flush=True)
2283
+
2284
+ all_loop_term_tic = time.perf_counter()
2285
+ app.logger.info(f"run_ID: {{run_ID}}; checking for loop terminations")
2286
+ for run_i in block_act_runs:
2287
+ if not run_skips[run_i.id_]:
2288
+ skipped_IDs_i = wk._check_loop_termination(run_i)
2289
+ for skip_ID in skipped_IDs_i:
2290
+ run_skips[skip_ID] = 2 # SkipReason.LOOP_TERMINATION
2291
+ if skip_ID in runs:
2292
+ runs[skip_ID]._skip = 2 # mutates runs within `run_end_dat`
2293
+ app.logger.info(f"run_ID: {{run_ID}}; finished checking for loop terminations.")
2294
+
2295
+ all_loop_term_toc = time.perf_counter()
2296
+ all_loop_term_time_i = all_loop_term_toc - all_loop_term_tic
2297
+ print(f"{{all_loop_term_time_i:.4f}}", file=loop_term_times_fp, flush=True)
2298
+
2299
+ # set run end for all elements of this action
2300
+ app.logger.info(f"run_ID: {{run_ID}}; setting run ends.")
2301
+ set_multi_end_tic = time.perf_counter()
2302
+ wk.set_multi_run_ends(run_end_dat)
2303
+ set_multi_end_toc = time.perf_counter()
2304
+ set_multi_end_time = set_multi_end_toc - set_multi_end_tic
2305
+ app.logger.info(f"run_ID: {{run_ID}}; finished setting run ends.")
2306
+ print(f"{{set_multi_end_time:.4f}}", file=set_end_multi_times_fp, flush=True)
2307
+
2308
+ block_start_elem_idx += num_elements[block_idx]
2309
+
2310
+ get_ins_time_fp.close()
2311
+ func_time_fp.close()
2312
+ run_time_fp.close()
2313
+ set_start_multi_times_fp.close()
2314
+ set_end_multi_times_fp.close()
2315
+ save_multi_times_fp.close()
2316
+ loop_term_times_fp.close()
2317
+ """
2318
+ ).format(
2319
+ py_imports=py_imports,
2320
+ py_main_block_workflow_load=indent(py_main_block_workflow_load, tab_indent),
2321
+ app_caps=self._app.package_name.upper(),
2322
+ script_idx_delim=",", # TODO
2323
+ script_names=script_names_str,
2324
+ requires_dir=requires_dir,
2325
+ num_blocks=len(self.blocks),
2326
+ run_ID_delim=self._EAR_files_delimiter,
2327
+ run_log_name=self.submission.get_app_log_file_name(run_ID="{run_ID}"),
2328
+ js_idx=self.index,
2329
+ write_app_logs=self.resources.write_app_logs,
2330
+ sub_log_path=sub_log_path,
2331
+ skipped_exit_code=SKIPPED_EXIT_CODE,
2332
+ func_invoc_lines=indent(func_invoc_lines, tab_indent * 4),
2333
+ )
2334
+
2335
+ future_imports_str = (
2336
+ f"from __future__ import {', '.join(future_imports)}\n\n"
2337
+ if future_imports
2338
+ else ""
2339
+ )
2340
+ script = dedent(
2341
+ """\
2342
+ {future_imports_str}{script_funcs}
2343
+ if __name__ == "__main__":
2344
+ {main}
2345
+ """
2346
+ ).format(
2347
+ future_imports_str=future_imports_str,
2348
+ script_funcs=script_funcs,
2349
+ main=indent(main, tab_indent),
2350
+ )
2351
+
2352
+ num_elems = [i.num_elements for i in self.blocks]
2353
+ num_acts = [len(i) for i in action_scripts]
2354
+
2355
+ return script, script_indices, num_elems, num_acts
2356
+
2357
+ def write_script_indices_file(
2358
+ self, indices: list[list[int]], num_elems: list[int], num_acts: list[int]
2359
+ ) -> None:
2360
+ """
2361
+ Write a text file containing the action script index for each block and action
2362
+ in a `combined_scripts` script.
2363
+ """
2364
+ delim = "," # TODO: refactor?
2365
+ with self.combined_script_indices_file_path.open("wt") as fp:
2366
+ fp.write("# number of elements per block:\n")
2367
+ fp.write(delim.join(str(i) for i in num_elems) + "\n")
2368
+ fp.write("# number of actions per block:\n")
2369
+ fp.write(delim.join(str(i) for i in num_acts) + "\n")
2370
+ fp.write("# script indices:\n")
2371
+ for block in indices:
2372
+ fp.write(delim.join(str(i) for i in block) + "\n")
2373
+
2374
+ def get_app_std_path(self) -> Path:
2375
+ std_dir = self.submission.get_app_std_path(
2376
+ self.workflow.submissions_path,
2377
+ self.submission.index,
2378
+ )
2379
+ return std_dir / f"js_{self.index}.txt" # TODO: refactor