hpcflow-new2 0.2.0a189__py3-none-any.whl → 0.2.0a199__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. hpcflow/__pyinstaller/hook-hpcflow.py +9 -6
  2. hpcflow/_version.py +1 -1
  3. hpcflow/app.py +1 -0
  4. hpcflow/data/scripts/bad_script.py +2 -0
  5. hpcflow/data/scripts/do_nothing.py +2 -0
  6. hpcflow/data/scripts/env_specifier_test/input_file_generator_pass_env_spec.py +4 -0
  7. hpcflow/data/scripts/env_specifier_test/main_script_test_pass_env_spec.py +8 -0
  8. hpcflow/data/scripts/env_specifier_test/output_file_parser_pass_env_spec.py +4 -0
  9. hpcflow/data/scripts/env_specifier_test/v1/input_file_generator_basic.py +4 -0
  10. hpcflow/data/scripts/env_specifier_test/v1/main_script_test_direct_in_direct_out.py +7 -0
  11. hpcflow/data/scripts/env_specifier_test/v1/output_file_parser_basic.py +4 -0
  12. hpcflow/data/scripts/env_specifier_test/v2/main_script_test_direct_in_direct_out.py +7 -0
  13. hpcflow/data/scripts/input_file_generator_basic.py +3 -0
  14. hpcflow/data/scripts/input_file_generator_basic_FAIL.py +3 -0
  15. hpcflow/data/scripts/input_file_generator_test_stdout_stderr.py +8 -0
  16. hpcflow/data/scripts/main_script_test_direct_in.py +3 -0
  17. hpcflow/data/scripts/main_script_test_direct_in_direct_out_2.py +6 -0
  18. hpcflow/data/scripts/main_script_test_direct_in_direct_out_2_fail_allowed.py +6 -0
  19. hpcflow/data/scripts/main_script_test_direct_in_direct_out_2_fail_allowed_group.py +7 -0
  20. hpcflow/data/scripts/main_script_test_direct_in_direct_out_3.py +6 -0
  21. hpcflow/data/scripts/main_script_test_direct_in_group_direct_out_3.py +6 -0
  22. hpcflow/data/scripts/main_script_test_direct_in_group_one_fail_direct_out_3.py +6 -0
  23. hpcflow/data/scripts/main_script_test_hdf5_in_obj.py +1 -1
  24. hpcflow/data/scripts/main_script_test_hdf5_in_obj_2.py +12 -0
  25. hpcflow/data/scripts/main_script_test_hdf5_out_obj.py +1 -1
  26. hpcflow/data/scripts/main_script_test_json_out_FAIL.py +3 -0
  27. hpcflow/data/scripts/main_script_test_shell_env_vars.py +12 -0
  28. hpcflow/data/scripts/main_script_test_std_out_std_err.py +6 -0
  29. hpcflow/data/scripts/output_file_parser_basic.py +3 -0
  30. hpcflow/data/scripts/output_file_parser_basic_FAIL.py +7 -0
  31. hpcflow/data/scripts/output_file_parser_test_stdout_stderr.py +8 -0
  32. hpcflow/data/scripts/script_exit_test.py +5 -0
  33. hpcflow/data/template_components/environments.yaml +1 -1
  34. hpcflow/sdk/__init__.py +26 -15
  35. hpcflow/sdk/app.py +2192 -768
  36. hpcflow/sdk/cli.py +506 -296
  37. hpcflow/sdk/cli_common.py +105 -7
  38. hpcflow/sdk/config/__init__.py +1 -1
  39. hpcflow/sdk/config/callbacks.py +115 -43
  40. hpcflow/sdk/config/cli.py +126 -103
  41. hpcflow/sdk/config/config.py +674 -318
  42. hpcflow/sdk/config/config_file.py +131 -95
  43. hpcflow/sdk/config/errors.py +125 -84
  44. hpcflow/sdk/config/types.py +148 -0
  45. hpcflow/sdk/core/__init__.py +25 -1
  46. hpcflow/sdk/core/actions.py +1771 -1059
  47. hpcflow/sdk/core/app_aware.py +24 -0
  48. hpcflow/sdk/core/cache.py +139 -79
  49. hpcflow/sdk/core/command_files.py +263 -287
  50. hpcflow/sdk/core/commands.py +145 -112
  51. hpcflow/sdk/core/element.py +828 -535
  52. hpcflow/sdk/core/enums.py +192 -0
  53. hpcflow/sdk/core/environment.py +74 -93
  54. hpcflow/sdk/core/errors.py +455 -52
  55. hpcflow/sdk/core/execute.py +207 -0
  56. hpcflow/sdk/core/json_like.py +540 -272
  57. hpcflow/sdk/core/loop.py +751 -347
  58. hpcflow/sdk/core/loop_cache.py +164 -47
  59. hpcflow/sdk/core/object_list.py +370 -207
  60. hpcflow/sdk/core/parameters.py +1100 -627
  61. hpcflow/sdk/core/rule.py +59 -41
  62. hpcflow/sdk/core/run_dir_files.py +21 -37
  63. hpcflow/sdk/core/skip_reason.py +7 -0
  64. hpcflow/sdk/core/task.py +1649 -1339
  65. hpcflow/sdk/core/task_schema.py +308 -196
  66. hpcflow/sdk/core/test_utils.py +191 -114
  67. hpcflow/sdk/core/types.py +440 -0
  68. hpcflow/sdk/core/utils.py +485 -309
  69. hpcflow/sdk/core/validation.py +82 -9
  70. hpcflow/sdk/core/workflow.py +2544 -1178
  71. hpcflow/sdk/core/zarr_io.py +98 -137
  72. hpcflow/sdk/data/workflow_spec_schema.yaml +2 -0
  73. hpcflow/sdk/demo/cli.py +53 -33
  74. hpcflow/sdk/helper/cli.py +18 -15
  75. hpcflow/sdk/helper/helper.py +75 -63
  76. hpcflow/sdk/helper/watcher.py +61 -28
  77. hpcflow/sdk/log.py +122 -71
  78. hpcflow/sdk/persistence/__init__.py +8 -31
  79. hpcflow/sdk/persistence/base.py +1360 -606
  80. hpcflow/sdk/persistence/defaults.py +6 -0
  81. hpcflow/sdk/persistence/discovery.py +38 -0
  82. hpcflow/sdk/persistence/json.py +568 -188
  83. hpcflow/sdk/persistence/pending.py +382 -179
  84. hpcflow/sdk/persistence/store_resource.py +39 -23
  85. hpcflow/sdk/persistence/types.py +318 -0
  86. hpcflow/sdk/persistence/utils.py +14 -11
  87. hpcflow/sdk/persistence/zarr.py +1337 -433
  88. hpcflow/sdk/runtime.py +44 -41
  89. hpcflow/sdk/submission/{jobscript_info.py → enums.py} +39 -12
  90. hpcflow/sdk/submission/jobscript.py +1651 -692
  91. hpcflow/sdk/submission/schedulers/__init__.py +167 -39
  92. hpcflow/sdk/submission/schedulers/direct.py +121 -81
  93. hpcflow/sdk/submission/schedulers/sge.py +170 -129
  94. hpcflow/sdk/submission/schedulers/slurm.py +291 -268
  95. hpcflow/sdk/submission/schedulers/utils.py +12 -2
  96. hpcflow/sdk/submission/shells/__init__.py +14 -15
  97. hpcflow/sdk/submission/shells/base.py +150 -29
  98. hpcflow/sdk/submission/shells/bash.py +283 -173
  99. hpcflow/sdk/submission/shells/os_version.py +31 -30
  100. hpcflow/sdk/submission/shells/powershell.py +228 -170
  101. hpcflow/sdk/submission/submission.py +1014 -335
  102. hpcflow/sdk/submission/types.py +140 -0
  103. hpcflow/sdk/typing.py +182 -12
  104. hpcflow/sdk/utils/arrays.py +71 -0
  105. hpcflow/sdk/utils/deferred_file.py +55 -0
  106. hpcflow/sdk/utils/hashing.py +16 -0
  107. hpcflow/sdk/utils/patches.py +12 -0
  108. hpcflow/sdk/utils/strings.py +33 -0
  109. hpcflow/tests/api/test_api.py +32 -0
  110. hpcflow/tests/conftest.py +27 -6
  111. hpcflow/tests/data/multi_path_sequences.yaml +29 -0
  112. hpcflow/tests/data/workflow_test_run_abort.yaml +34 -35
  113. hpcflow/tests/schedulers/sge/test_sge_submission.py +36 -0
  114. hpcflow/tests/schedulers/slurm/test_slurm_submission.py +5 -2
  115. hpcflow/tests/scripts/test_input_file_generators.py +282 -0
  116. hpcflow/tests/scripts/test_main_scripts.py +866 -85
  117. hpcflow/tests/scripts/test_non_snippet_script.py +46 -0
  118. hpcflow/tests/scripts/test_ouput_file_parsers.py +353 -0
  119. hpcflow/tests/shells/wsl/test_wsl_submission.py +12 -4
  120. hpcflow/tests/unit/test_action.py +262 -75
  121. hpcflow/tests/unit/test_action_rule.py +9 -4
  122. hpcflow/tests/unit/test_app.py +33 -6
  123. hpcflow/tests/unit/test_cache.py +46 -0
  124. hpcflow/tests/unit/test_cli.py +134 -1
  125. hpcflow/tests/unit/test_command.py +71 -54
  126. hpcflow/tests/unit/test_config.py +142 -16
  127. hpcflow/tests/unit/test_config_file.py +21 -18
  128. hpcflow/tests/unit/test_element.py +58 -62
  129. hpcflow/tests/unit/test_element_iteration.py +50 -1
  130. hpcflow/tests/unit/test_element_set.py +29 -19
  131. hpcflow/tests/unit/test_group.py +4 -2
  132. hpcflow/tests/unit/test_input_source.py +116 -93
  133. hpcflow/tests/unit/test_input_value.py +29 -24
  134. hpcflow/tests/unit/test_jobscript_unit.py +757 -0
  135. hpcflow/tests/unit/test_json_like.py +44 -35
  136. hpcflow/tests/unit/test_loop.py +1396 -84
  137. hpcflow/tests/unit/test_meta_task.py +325 -0
  138. hpcflow/tests/unit/test_multi_path_sequences.py +229 -0
  139. hpcflow/tests/unit/test_object_list.py +17 -12
  140. hpcflow/tests/unit/test_parameter.py +29 -7
  141. hpcflow/tests/unit/test_persistence.py +237 -42
  142. hpcflow/tests/unit/test_resources.py +20 -18
  143. hpcflow/tests/unit/test_run.py +117 -6
  144. hpcflow/tests/unit/test_run_directories.py +29 -0
  145. hpcflow/tests/unit/test_runtime.py +2 -1
  146. hpcflow/tests/unit/test_schema_input.py +23 -15
  147. hpcflow/tests/unit/test_shell.py +23 -2
  148. hpcflow/tests/unit/test_slurm.py +8 -7
  149. hpcflow/tests/unit/test_submission.py +38 -89
  150. hpcflow/tests/unit/test_task.py +352 -247
  151. hpcflow/tests/unit/test_task_schema.py +33 -20
  152. hpcflow/tests/unit/test_utils.py +9 -11
  153. hpcflow/tests/unit/test_value_sequence.py +15 -12
  154. hpcflow/tests/unit/test_workflow.py +114 -83
  155. hpcflow/tests/unit/test_workflow_template.py +0 -1
  156. hpcflow/tests/unit/utils/test_arrays.py +40 -0
  157. hpcflow/tests/unit/utils/test_deferred_file_writer.py +34 -0
  158. hpcflow/tests/unit/utils/test_hashing.py +65 -0
  159. hpcflow/tests/unit/utils/test_patches.py +5 -0
  160. hpcflow/tests/unit/utils/test_redirect_std.py +50 -0
  161. hpcflow/tests/workflows/__init__.py +0 -0
  162. hpcflow/tests/workflows/test_directory_structure.py +31 -0
  163. hpcflow/tests/workflows/test_jobscript.py +334 -1
  164. hpcflow/tests/workflows/test_run_status.py +198 -0
  165. hpcflow/tests/workflows/test_skip_downstream.py +696 -0
  166. hpcflow/tests/workflows/test_submission.py +140 -0
  167. hpcflow/tests/workflows/test_workflows.py +160 -15
  168. hpcflow/tests/workflows/test_zip.py +18 -0
  169. hpcflow/viz_demo.ipynb +6587 -3
  170. {hpcflow_new2-0.2.0a189.dist-info → hpcflow_new2-0.2.0a199.dist-info}/METADATA +8 -4
  171. hpcflow_new2-0.2.0a199.dist-info/RECORD +221 -0
  172. hpcflow/sdk/core/parallel.py +0 -21
  173. hpcflow_new2-0.2.0a189.dist-info/RECORD +0 -158
  174. {hpcflow_new2-0.2.0a189.dist-info → hpcflow_new2-0.2.0a199.dist-info}/LICENSE +0 -0
  175. {hpcflow_new2-0.2.0a189.dist-info → hpcflow_new2-0.2.0a199.dist-info}/WHEEL +0 -0
  176. {hpcflow_new2-0.2.0a189.dist-info → hpcflow_new2-0.2.0a199.dist-info}/entry_points.txt +0 -0
@@ -14,42 +14,45 @@ import os
14
14
  import socket
15
15
  import uuid
16
16
  from dataclasses import dataclass, field
17
- from hashlib import new
18
17
  from pathlib import Path
19
- from typing import Dict, List, Optional, Tuple, Union
20
- import fsspec
18
+ from typing import cast, overload, TYPE_CHECKING
19
+ import fsspec # type: ignore
21
20
 
22
21
  from rich.console import Console, Group
23
22
  from rich.table import Table
24
23
  from rich.pretty import Pretty
25
24
  from rich.panel import Panel
26
25
  from rich import print as rich_print
27
- from fsspec.registry import known_implementations as fsspec_protocols
28
- from fsspec.implementations.local import LocalFileSystem
29
- from platformdirs import user_data_dir
30
- from valida.schema import Schema
26
+ from fsspec.registry import known_implementations as fsspec_protocols # type: ignore
27
+ from fsspec.implementations.local import LocalFileSystem # type: ignore
31
28
  from hpcflow.sdk.core.utils import get_in_container, read_YAML_file, set_in_container
32
29
 
33
- from hpcflow.sdk.core.validation import get_schema
30
+ from hpcflow.sdk.core.validation import get_schema, Schema
34
31
  from hpcflow.sdk.submission.shells import DEFAULT_SHELL_NAMES
35
32
  from hpcflow.sdk.typing import PathLike
36
33
 
37
- from .callbacks import (
34
+ from hpcflow.sdk.config.callbacks import (
38
35
  callback_bool,
39
36
  callback_lowercase,
40
37
  callback_scheduler_set_up,
41
38
  callback_supported_schedulers,
42
39
  callback_supported_shells,
43
40
  callback_update_log_console_level,
41
+ callback_unset_log_console_level,
44
42
  callback_vars,
45
43
  callback_file_paths,
46
44
  exists_in_schedulers,
47
45
  set_callback_file_paths,
48
46
  check_load_data_files,
49
47
  set_scheduler_invocation_match,
48
+ callback_update_log_file_path,
49
+ callback_update_log_file_level,
50
+ callback_unset_log_file_level,
51
+ callback_unset_log_file_path,
52
+ callback_log_file_path,
50
53
  )
51
- from .config_file import ConfigFile
52
- from .errors import (
54
+ from hpcflow.sdk.config.config_file import ConfigFile
55
+ from hpcflow.sdk.config.errors import (
53
56
  ConfigChangeInvalidJSONError,
54
57
  ConfigChangePopIndexError,
55
58
  ConfigChangeTypeInvalidError,
@@ -57,16 +60,35 @@ from .errors import (
57
60
  ConfigItemAlreadyUnsetError,
58
61
  ConfigItemCallbackError,
59
62
  ConfigNonConfigurableError,
63
+ ConfigReadOnlyError,
60
64
  ConfigUnknownItemError,
61
65
  ConfigUnknownOverrideError,
62
66
  ConfigValidationError,
63
67
  )
64
68
 
69
+ if TYPE_CHECKING:
70
+ from collections.abc import Callable, Iterator, Mapping, Sequence
71
+ from typing import Any, Literal
72
+ from .types import (
73
+ ConfigDescriptor,
74
+ ConfigMetadata,
75
+ DefaultConfiguration,
76
+ SchedulerConfigDescriptor,
77
+ ShellConfigDescriptor,
78
+ GetterCallback,
79
+ SetterCallback,
80
+ UnsetterCallback,
81
+ T,
82
+ )
83
+ from ..app import BaseApp
84
+ from ..core.types import AbstractFileSystem
85
+
86
+
65
87
  logger = logging.getLogger(__name__)
66
88
 
67
89
  _DEFAULT_SHELL = DEFAULT_SHELL_NAMES[os.name]
68
90
  #: The default configuration descriptor.
69
- DEFAULT_CONFIG = {
91
+ DEFAULT_CONFIG: DefaultConfiguration = {
70
92
  "invocation": {"environment_setup": None, "match": {}},
71
93
  "config": {
72
94
  "machine": socket.gethostname(),
@@ -88,29 +110,29 @@ class ConfigOptions:
88
110
  """Application-level options for configuration"""
89
111
 
90
112
  #: The default directory.
91
- default_directory: Union[Path, str]
113
+ default_directory: Path | str
92
114
  #: The environment variable containing the directory name.
93
115
  directory_env_var: str
94
116
  #: The default configuration.
95
- default_config: Optional[Dict] = field(
117
+ default_config: DefaultConfiguration = field(
96
118
  default_factory=lambda: deepcopy(DEFAULT_CONFIG)
97
119
  )
98
120
  #: Any extra schemas to apply.
99
- extra_schemas: Optional[List[Schema]] = field(default_factory=lambda: [])
121
+ extra_schemas: Sequence[Schema] = field(default_factory=list)
100
122
  #: Default directory of known configurations.
101
- default_known_configs_dir: Optional[str] = None
123
+ default_known_configs_dir: str | None = None
124
+ _schemas: Sequence[Schema] = field(init=False)
125
+ _configurable_keys: Sequence[str] = field(init=False)
102
126
 
103
- def __post_init__(self):
104
- cfg_schemas, cfg_keys = self.init_schemas()
105
- self._schemas = cfg_schemas
106
- self._configurable_keys = cfg_keys
127
+ def __post_init__(self) -> None:
128
+ self._schemas, self._configurable_keys = self.init_schemas()
107
129
 
108
- def init_schemas(self):
130
+ def init_schemas(self) -> tuple[Sequence[Schema], Sequence[str]]:
109
131
  """
110
132
  Get allowed configurable keys from config schemas.
111
133
  """
112
- cfg_schemas = [get_schema("config_schema.yaml")] + self.extra_schemas
113
- cfg_keys = []
134
+ cfg_schemas = [get_schema("config_schema.yaml"), *self.extra_schemas]
135
+ cfg_keys: list[str] = []
114
136
  for cfg_schema in cfg_schemas:
115
137
  for rule in cfg_schema.rules:
116
138
  if not rule.path and rule.condition.callable.name == "allowed_keys":
@@ -118,7 +140,13 @@ class ConfigOptions:
118
140
 
119
141
  return (cfg_schemas, cfg_keys)
120
142
 
121
- def validate(self, data, logger, metadata=None, raise_with_metadata=True):
143
+ def validate(
144
+ self,
145
+ data: T,
146
+ logger: logging.Logger,
147
+ metadata: ConfigMetadata | None = None,
148
+ raise_with_metadata: bool = True,
149
+ ) -> T:
122
150
  """Validate configuration items of the loaded invocation."""
123
151
 
124
152
  logger.debug("Validating configuration...")
@@ -140,7 +168,8 @@ class ConfigOptions:
140
168
 
141
169
 
142
170
  class Config:
143
- """Application configuration as defined in one or more config files.
171
+ """
172
+ Application configuration as defined in one or more config files.
144
173
 
145
174
  This class supports indexing into the collection of properties via Python dot notation.
146
175
 
@@ -178,76 +207,25 @@ class Config:
178
207
 
179
208
  Attributes
180
209
  ----------
181
- config_directory:
182
- The directory containing the configuration file.
183
- config_file_name:
184
- The name of the configuration file.
185
- config_file_path:
186
- The full path to the configuration file.
187
- config_file_contents:
188
- The cached contents of the configuration file.
189
- config_key:
190
- The primary key to select the configuration within the configuration file.
191
- config_schemas:
192
- The schemas that apply to the configuration file.
193
- host_user_id:
194
- User ID as understood by the script.
195
- host_user_id_file_path:
196
- Where user ID information is stored.
197
- invoking_user_id:
198
- User ID that created the workflow.
199
- machine:
200
- Machine to submit to.
201
- Mapped to a field in the configuration file.
202
- user_name:
210
+ user_name: str
203
211
  User to submit as.
204
212
  Mapped to a field in the configuration file.
205
- user_orcid:
213
+ user_orcid: str
206
214
  User's ORCID.
207
215
  Mapped to a field in the configuration file.
208
- user_affiliation:
216
+ user_affiliation: str
209
217
  User's institutional affiliation.
210
218
  Mapped to a field in the configuration file.
211
- linux_release_file:
219
+ linux_release_file: str
212
220
  Where to get the description of the Linux release version data.
213
221
  Mapped to a field in the configuration file.
214
- log_file_path:
215
- Where to log to.
216
- Mapped to a field in the configuration file.
217
- log_file_level:
222
+ log_file_level: str
218
223
  At what level to do logging to the file.
219
224
  Mapped to a field in the configuration file.
220
- log_console_level:
225
+ log_console_level: str
221
226
  At what level to do logging to the console. Usually coarser than to a file.
222
227
  Mapped to a field in the configuration file.
223
- task_schema_sources:
224
- Where to get task schemas.
225
- Mapped to a field in the configuration file.
226
- parameter_sources:
227
- Where to get parameter descriptors.
228
- Mapped to a field in the configuration file.
229
- command_file_sources:
230
- Where to get command files.
231
- Mapped to a field in the configuration file.
232
- environment_sources:
233
- Where to get execution environment descriptors.
234
- Mapped to a field in the configuration file.
235
- default_scheduler:
236
- The name of the default scheduler.
237
- Mapped to a field in the configuration file.
238
- default_shell:
239
- The name of the default shell.
240
- Mapped to a field in the configuration file.
241
- schedulers:
242
- Settings for supported scheduler(s).
243
- Mapped to a field in the configuration file.
244
- shells:
245
- Settings for supported shell(s).
246
- Mapped to a field in the configuration file.
247
- demo_data_dir:
248
- Location of demo data.
249
- Mapped to a field in the configuration file.
250
- demo_data_manifest_file:
228
+ demo_data_manifest_file: str
251
229
  Where the manifest describing the demo data is.
252
230
  Mapped to a field in the configuration file.
253
231
  """
@@ -258,10 +236,10 @@ class Config:
258
236
  config_file: ConfigFile,
259
237
  options: ConfigOptions,
260
238
  logger: logging.Logger,
261
- config_key: Optional[str],
262
- uid=None,
263
- callbacks=None,
264
- variables=None,
239
+ config_key: str | None,
240
+ uid: str | None = None,
241
+ callbacks: dict[str, tuple[GetterCallback, ...]] | None = None,
242
+ variables: dict[str, str] | None = None,
265
243
  **overrides,
266
244
  ):
267
245
  self._app = app
@@ -281,12 +259,12 @@ class Config:
281
259
  )
282
260
 
283
261
  # Callbacks are run on get:
284
- self._get_callbacks = {
262
+ self._get_callbacks: dict[str, tuple[GetterCallback, ...]] = {
285
263
  "task_schema_sources": (callback_file_paths,),
286
264
  "environment_sources": (callback_file_paths,),
287
265
  "parameter_sources": (callback_file_paths,),
288
266
  "command_file_sources": (callback_file_paths,),
289
- "log_file_path": (callback_vars, callback_file_paths),
267
+ "log_file_path": (callback_vars, callback_log_file_path),
290
268
  "telemetry": (callback_bool,),
291
269
  "schedulers": (callback_lowercase, callback_supported_schedulers),
292
270
  "shells": (callback_lowercase,),
@@ -297,7 +275,7 @@ class Config:
297
275
  }
298
276
 
299
277
  # Set callbacks are run on set:
300
- self._set_callbacks = {
278
+ self._set_callbacks: dict[str, tuple[SetterCallback, ...]] = {
301
279
  "task_schema_sources": (set_callback_file_paths, check_load_data_files),
302
280
  "environment_sources": (set_callback_file_paths, check_load_data_files),
303
281
  "parameter_sources": (set_callback_file_paths, check_load_data_files),
@@ -305,22 +283,28 @@ class Config:
305
283
  "default_scheduler": (exists_in_schedulers, set_scheduler_invocation_match),
306
284
  "default_shell": (callback_supported_shells,),
307
285
  "schedulers": (callback_supported_schedulers, callback_scheduler_set_up),
308
- "log_file_path": (set_callback_file_paths,),
286
+ "log_file_path": (callback_update_log_file_path,),
287
+ "log_file_level": (callback_update_log_file_level,),
309
288
  "log_console_level": (callback_update_log_console_level,),
310
289
  "demo_data_manifest_file": (set_callback_file_paths,),
311
290
  }
312
291
 
292
+ self._unset_callbacks: dict[str, tuple[UnsetterCallback, ...]] = {
293
+ "log_console_level": (callback_unset_log_console_level,),
294
+ "log_file_level": (callback_unset_log_file_level,),
295
+ "log_file_path": (callback_unset_log_file_path,),
296
+ }
297
+
313
298
  self._configurable_keys = self._options._configurable_keys
314
- self._modified_keys = {}
315
- self._unset_keys = []
299
+ self._modified_keys: ConfigDescriptor = {}
300
+ self._unset_keys: set[str] = set()
316
301
 
317
- for name in overrides:
318
- if name not in self._configurable_keys:
319
- raise ConfigUnknownOverrideError(name=name)
302
+ if any((unknown := name) not in self._configurable_keys for name in overrides):
303
+ raise ConfigUnknownOverrideError(name=unknown)
320
304
 
321
305
  host_uid, host_uid_file_path = self._get_user_id()
322
306
 
323
- metadata = {
307
+ metadata: ConfigMetadata = {
324
308
  "config_directory": self._file.directory,
325
309
  "config_file_name": self._file.path.name,
326
310
  "config_file_path": self._file.path,
@@ -333,22 +317,222 @@ class Config:
333
317
  }
334
318
  self._meta_data = metadata
335
319
 
320
+ # used within context manager `cached_config`:
321
+ self._use_cache = False
322
+ self._config_cache: dict[tuple[str, bool, bool, bool], Any] = {}
323
+
324
+ # note: this must go at the end, after all instance attributes have been set!
336
325
  self._options.validate(
337
326
  data=self.get_all(include_overrides=True),
338
327
  logger=self._logger,
339
328
  metadata=metadata,
340
329
  )
341
330
 
342
- def __dir__(self):
343
- return super().__dir__() + self._all_keys
331
+ def __dir__(self) -> Iterator[str]:
332
+ yield from super().__dir__()
333
+ yield from self._all_keys
344
334
 
345
- def __getattr__(self, name):
346
- if not name.startswith("__"):
347
- return self._get(name)
348
- else:
335
+ @property
336
+ def config_directory(self) -> Path:
337
+ """
338
+ The directory containing the configuration file.
339
+ """
340
+ return self._get("config_directory")
341
+
342
+ @property
343
+ def config_file_name(self) -> str:
344
+ """
345
+ The name of the configuration file.
346
+ """
347
+ return self._get("config_file_name")
348
+
349
+ @property
350
+ def config_file_path(self) -> Path:
351
+ """
352
+ The full path to the configuration file.
353
+ """
354
+ return self._get("config_file_path")
355
+
356
+ @property
357
+ def config_file_contents(self) -> str:
358
+ """
359
+ The cached contents of the configuration file.
360
+ """
361
+ return self._get("config_file_contents")
362
+
363
+ @property
364
+ def config_key(self) -> str:
365
+ """
366
+ The primary key to select the configuration within the configuration file.
367
+ """
368
+ return self._get("config_key")
369
+
370
+ @property
371
+ def config_schemas(self) -> Sequence[Schema]:
372
+ """
373
+ The schemas that apply to the configuration file.
374
+ """
375
+ return self._get("config_schemas")
376
+
377
+ @property
378
+ def invoking_user_id(self) -> str:
379
+ """
380
+ User ID that created the workflow.
381
+ """
382
+ return self._get("invoking_user_id")
383
+
384
+ @property
385
+ def host_user_id(self) -> str:
386
+ """
387
+ User ID as understood by the script.
388
+ """
389
+ return self._get("host_user_id")
390
+
391
+ @property
392
+ def host_user_id_file_path(self) -> Path:
393
+ """
394
+ Where user ID information is stored.
395
+ """
396
+ return self._get("host_user_id_file_path")
397
+
398
+ @property
399
+ def machine(self) -> str:
400
+ """
401
+ Machine to submit to.
402
+ Mapped to a field in the configuration file.
403
+ """
404
+ return self._get("machine")
405
+
406
+ @machine.setter
407
+ def machine(self, value: str):
408
+ self._set("machine", value)
409
+
410
+ @property
411
+ def log_file_path(self) -> str:
412
+ """
413
+ Where to log to.
414
+ Mapped to a field in the configuration file.
415
+ """
416
+ return self._get("log_file_path")
417
+
418
+ @log_file_path.setter
419
+ def log_file_path(self, value: str):
420
+ self._set("log_file_path", value)
421
+
422
+ @property
423
+ def environment_sources(self) -> Sequence[Path]:
424
+ """
425
+ Where to get execution environment descriptors.
426
+ Mapped to a field in the configuration file.
427
+ """
428
+ return self._get("environment_sources")
429
+
430
+ @environment_sources.setter
431
+ def environment_sources(self, value: Sequence[Path]):
432
+ self._set("environment_sources", value)
433
+
434
+ @property
435
+ def task_schema_sources(self) -> Sequence[str]:
436
+ """
437
+ Where to get task schemas.
438
+ Mapped to a field in the configuration file.
439
+ """
440
+ return self._get("task_schema_sources")
441
+
442
+ @task_schema_sources.setter
443
+ def task_schema_sources(self, value: Sequence[str]):
444
+ self._set("task_schema_sources", value)
445
+
446
+ @property
447
+ def command_file_sources(self) -> Sequence[str]:
448
+ """
449
+ Where to get command files.
450
+ Mapped to a field in the configuration file.
451
+ """
452
+ return self._get("command_file_sources")
453
+
454
+ @command_file_sources.setter
455
+ def command_file_sources(self, value: Sequence[str]):
456
+ self._set("command_file_sources", value)
457
+
458
+ @property
459
+ def parameter_sources(self) -> Sequence[str]:
460
+ """
461
+ Where to get parameter descriptors.
462
+ Mapped to a field in the configuration file.
463
+ """
464
+ return self._get("parameter_sources")
465
+
466
+ @parameter_sources.setter
467
+ def parameter_sources(self, value: Sequence[str]):
468
+ self._set("parameter_sources", value)
469
+
470
+ @property
471
+ def default_scheduler(self) -> str:
472
+ """
473
+ The name of the default scheduler.
474
+ Mapped to a field in the configuration file.
475
+ """
476
+ return self._get("default_scheduler")
477
+
478
+ @default_scheduler.setter
479
+ def default_scheduler(self, value: str):
480
+ self._set("default_scheduler", value)
481
+
482
+ @property
483
+ def default_shell(self) -> str:
484
+ """
485
+ The name of the default shell.
486
+ Mapped to a field in the configuration file.
487
+ """
488
+ return self._get("default_shell")
489
+
490
+ @default_shell.setter
491
+ def default_shell(self, value: str):
492
+ self._set("default_shell", value)
493
+
494
+ @property
495
+ def schedulers(self) -> Mapping[str, SchedulerConfigDescriptor]:
496
+ """
497
+ Settings for supported scheduler(s).
498
+ Mapped to a field in the configuration file.
499
+ """
500
+ return self._get("schedulers")
501
+
502
+ @schedulers.setter
503
+ def schedulers(self, value: Mapping[str, SchedulerConfigDescriptor]):
504
+ self._set("schedulers", value)
505
+
506
+ @property
507
+ def shells(self) -> Mapping[str, ShellConfigDescriptor]:
508
+ """
509
+ Settings for supported shell(s).
510
+ Mapped to a field in the configuration file.
511
+ """
512
+ return self._get("shells")
513
+
514
+ @shells.setter
515
+ def shells(self, value: Mapping[str, ShellConfigDescriptor]):
516
+ self._set("shells", value)
517
+
518
+ @property
519
+ def demo_data_dir(self) -> str | None:
520
+ """
521
+ Location of demo data.
522
+ Mapped to a field in the configuration file.
523
+ """
524
+ return self._get("demo_data_dir")
525
+
526
+ @demo_data_dir.setter
527
+ def demo_data_dir(self, value: str | None):
528
+ self._set("demo_data_dir", value)
529
+
530
+ def __getattr__(self, name: str):
531
+ if name.startswith("__"):
349
532
  raise AttributeError(f"Attribute not known: {name!r}.")
533
+ return self._get(name)
350
534
 
351
- def __setattr__(self, name, value):
535
+ def __setattr__(self, name: str, value):
352
536
  if (
353
537
  "_configurable_keys" in self.__dict__
354
538
  and name in self.__dict__["_configurable_keys"]
@@ -357,37 +541,53 @@ class Config:
357
541
  else:
358
542
  super().__setattr__(name, value)
359
543
 
360
- def _disable_callbacks(self, callbacks) -> Tuple[Dict]:
361
- """Disable named get and set callbacks.
544
+ def _disable_callbacks(
545
+ self, callbacks: Sequence[str]
546
+ ) -> tuple[
547
+ dict[str, tuple[GetterCallback, ...]],
548
+ dict[str, tuple[SetterCallback, ...]],
549
+ dict[str, tuple[UnsetterCallback, ...]],
550
+ ]:
551
+ """
552
+ Disable named get, set, and unset callbacks.
362
553
 
363
554
  Returns
364
555
  -------
365
556
  The original get and set callback dictionaries.
366
557
  """
367
558
  self._logger.info(f"disabling config callbacks: {callbacks!r}")
368
- get_callbacks_tmp = {
369
- k: tuple(i for i in v if i.__name__ not in callbacks)
559
+ get_callbacks_tmp: dict[str, tuple[GetterCallback, ...]] = {
560
+ k: tuple(cb for cb in v if cb.__name__ not in callbacks)
370
561
  for k, v in self._get_callbacks.items()
371
562
  }
372
- set_callbacks_tmp = {
373
- k: tuple(i for i in v if i.__name__ not in callbacks)
563
+ set_callbacks_tmp: dict[str, tuple[SetterCallback, ...]] = {
564
+ k: tuple(cb for cb in v if cb.__name__ not in callbacks)
374
565
  for k, v in self._set_callbacks.items()
375
566
  }
567
+ unset_callbacks_tmp = {
568
+ k: tuple(i for i in v if i.__name__ not in callbacks)
569
+ for k, v in self._unset_callbacks.items()
570
+ }
376
571
  get_callbacks = copy.deepcopy(self._get_callbacks)
377
572
  set_callbacks = copy.deepcopy(self._set_callbacks)
573
+ unset_callbacks = copy.deepcopy(self._unset_callbacks)
378
574
  self._get_callbacks = get_callbacks_tmp
379
575
  self._set_callbacks = set_callbacks_tmp
380
- return (get_callbacks, set_callbacks)
576
+ self._unset_callbacks = unset_callbacks_tmp
577
+ return (get_callbacks, set_callbacks, unset_callbacks)
381
578
 
382
579
  @contextlib.contextmanager
383
- def _without_callbacks(self, *callbacks):
384
- """Context manager to temporarily exclude named get and set callbacks."""
385
- get_callbacks, set_callbacks = self._disable_callbacks(*callbacks)
386
- yield
387
- self._get_callbacks = get_callbacks
388
- self._set_callbacks = set_callbacks
389
-
390
- def _validate(self):
580
+ def _without_callbacks(self, *callbacks: str) -> Iterator[None]:
581
+ """Context manager to temporarily exclude named get, set, and unset callbacks."""
582
+ get_cb, set_cb, unset_cb = self._disable_callbacks(callbacks)
583
+ try:
584
+ yield
585
+ finally:
586
+ self._get_callbacks = get_cb
587
+ self._set_callbacks = set_cb
588
+ self._unset_callbacks = unset_cb
589
+
590
+ def _validate(self) -> None:
391
591
  data = self.get_all(include_overrides=True)
392
592
  self._options.validate(
393
593
  data=data,
@@ -396,95 +596,117 @@ class Config:
396
596
  raise_with_metadata=True,
397
597
  )
398
598
 
399
- def _resolve_path(self, path):
599
+ def _resolve_path(self, path: PathLike) -> PathLike:
400
600
  """Resolve a file path, but leave fsspec protocols alone."""
401
- if not any(str(path).startswith(i + ":") for i in fsspec_protocols):
402
- path = Path(path)
403
- path = path.expanduser()
404
- if not path.is_absolute():
405
- path = self._meta_data["config_directory"].joinpath(path)
406
- else:
601
+ if path is None:
602
+ return None
603
+ if any(str(path).startswith(i + ":") for i in fsspec_protocols):
407
604
  self._logger.debug(
408
605
  f"Not resolving path {path!r} because it looks like an `fsspec` URL."
409
606
  )
607
+ return path
608
+ real_path = Path(path).expanduser()
609
+ if real_path.is_absolute():
610
+ return real_path
611
+ return self._meta_data["config_directory"].joinpath(real_path)
612
+
613
+ def register_config_get_callback(
614
+ self, name: str
615
+ ) -> Callable[[GetterCallback], GetterCallback]:
616
+ """
617
+ Decorator to register a function as a configuration callback for a specified
618
+ configuration item name, to be invoked on `get` of the item.
619
+ """
410
620
 
411
- return path
412
-
413
- def register_config_get_callback(self, name):
414
- """Decorator to register a function as a configuration callback for a specified
415
- configuration item name, to be invoked on `get` of the item."""
416
-
417
- def decorator(func):
621
+ def decorator(func: GetterCallback) -> GetterCallback:
418
622
  if name in self._get_callbacks:
419
- self._get_callbacks[name] = tuple(
420
- list(self._get_callbacks[name]) + [func]
421
- )
623
+ self._get_callbacks[name] = self._get_callbacks[name] + (func,)
422
624
  else:
423
625
  self._get_callbacks[name] = (func,)
424
626
 
425
627
  @functools.wraps(func)
426
- def wrap(value):
427
- return func(value)
628
+ def wrap(config: Config, value: T) -> T:
629
+ return func(config, value)
428
630
 
429
631
  return wrap
430
632
 
431
633
  return decorator
432
634
 
433
- def register_config_set_callback(self, name):
434
- """Decorator to register a function as a configuration callback for a specified
435
- configuration item name, to be invoked on `set` of the item."""
635
+ def register_config_set_callback(
636
+ self, name: str
637
+ ) -> Callable[[SetterCallback], SetterCallback]:
638
+ """
639
+ Decorator to register a function as a configuration callback for a specified
640
+ configuration item name, to be invoked on `set` of the item.
641
+ """
436
642
 
437
- def decorator(func):
643
+ def decorator(func: SetterCallback) -> SetterCallback:
438
644
  if name in self._set_callbacks:
439
- self._set_callbacks[name] = tuple(
440
- list(self._set_callbacks[name]) + [func]
441
- )
645
+ self._set_callbacks[name] = self._set_callbacks[name] + (func,)
442
646
  else:
443
647
  self._set_callbacks[name] = (func,)
444
648
 
445
649
  @functools.wraps(func)
446
- def wrap(value):
447
- return func(value)
650
+ def wrap(config: Config, value: T) -> Any:
651
+ return func(config, value)
448
652
 
449
653
  return wrap
450
654
 
451
655
  return decorator
452
656
 
453
657
  @property
454
- def _all_keys(self):
455
- return self._configurable_keys + list(self._meta_data.keys())
456
-
457
- def get_all(self, include_overrides=True, as_str=False):
658
+ def _all_keys(self) -> list[str]:
659
+ return [*self._configurable_keys, *self._meta_data]
660
+
661
+ @overload
662
+ def get_all(
663
+ self, *, include_overrides: bool = True, as_str: Literal[True]
664
+ ) -> Mapping[str, str]:
665
+ ...
666
+
667
+ @overload
668
+ def get_all(
669
+ self, *, include_overrides: bool = True, as_str: Literal[False] = False
670
+ ) -> Mapping[str, Any]:
671
+ ...
672
+
673
+ def get_all(
674
+ self, *, include_overrides: bool = True, as_str: bool = False
675
+ ) -> Mapping[str, Any]:
458
676
  """Get all configurable items."""
459
- items = {}
677
+ items: dict[str, Any] = {}
460
678
  for key in self._configurable_keys:
461
679
  if key in self._unset_keys:
462
680
  continue
463
- else:
464
- try:
465
- val = self._get(
681
+ try:
682
+ if as_str:
683
+ items[key] = self._get(
466
684
  name=key,
467
685
  include_overrides=include_overrides,
468
686
  raise_on_missing=True,
469
- as_str=as_str,
687
+ as_str=True,
470
688
  )
471
- except ValueError:
472
- continue
473
- items.update({key: val})
689
+ else:
690
+ items[key] = self._get(
691
+ name=key,
692
+ include_overrides=include_overrides,
693
+ raise_on_missing=True,
694
+ )
695
+ except ValueError:
696
+ continue
474
697
  return items
475
698
 
476
- def _show(self, config=True, metadata=False):
477
- group_args = []
699
+ def _show(self, config: bool = True, metadata: bool = False):
700
+ group_args: list[Panel] = []
478
701
  if metadata:
479
- tab_md = Table(show_header=False, box=None)
480
- tab_md.add_column()
481
- tab_md.add_column()
702
+ tab = Table(show_header=False, box=None)
703
+ tab.add_column()
704
+ tab.add_column()
482
705
  for k, v in self._meta_data.items():
483
706
  if k == "config_file_contents":
484
707
  continue
485
- tab_md.add_row(k, Pretty(v))
486
- panel_md = Panel(tab_md, title="Config metadata")
487
- group_args.append(panel_md)
708
+ tab.add_row(k, Pretty(v))
709
+ group_args.append(Panel(tab, title="Config metadata"))
488
710
 
489
711
  if config:
490
712
  tab = Table(show_header=False, box=None)
@@ -492,15 +714,13 @@ class Config:
492
714
  tab.add_column()
493
715
  for k, v in self.get_all().items():
494
716
  tab.add_row(k, Pretty(v))
495
- panel = Panel(tab, title=f"Config {self._config_key!r}")
496
- group_args.append(panel)
717
+ group_args.append(Panel(tab, title=f"Config {self._config_key!r}"))
497
718
 
498
- group = Group(*group_args)
499
- rich_print(group)
719
+ rich_print(Group(*group_args))
500
720
 
501
- def _get_callback_value(self, name, value):
721
+ def _get_callback_value(self, name: str, value):
502
722
  if name in self._get_callbacks and value is not None:
503
- for cb in self._get_callbacks.get(name, []):
723
+ for cb in self._get_callbacks.get(name, ()):
504
724
  self._logger.debug(
505
725
  f"Invoking `config.get` callback ({cb.__name__!r}) for item {name!r}={value!r}"
506
726
  )
@@ -510,9 +730,36 @@ class Config:
510
730
  raise ConfigItemCallbackError(name, cb, err) from None
511
731
  return value
512
732
 
733
+ @overload
734
+ def _get(
735
+ self,
736
+ name: str,
737
+ *,
738
+ include_overrides=True,
739
+ raise_on_missing=False,
740
+ as_str: Literal[False] = False,
741
+ callback=True,
742
+ default_value=None,
743
+ ) -> Any:
744
+ ...
745
+
746
+ @overload
513
747
  def _get(
514
748
  self,
515
- name,
749
+ name: str,
750
+ *,
751
+ include_overrides=True,
752
+ raise_on_missing=False,
753
+ as_str: Literal[True],
754
+ callback=True,
755
+ default_value=None,
756
+ ) -> list[str] | str:
757
+ ...
758
+
759
+ def _get(
760
+ self,
761
+ name: str,
762
+ *,
516
763
  include_overrides=True,
517
764
  raise_on_missing=False,
518
765
  as_str=False,
@@ -521,11 +768,22 @@ class Config:
521
768
  ):
522
769
  """Get a configuration item."""
523
770
 
771
+ if self._use_cache:
772
+ # note: we default_value is not necessarily hashable, so we can't cache on it!
773
+ key = (
774
+ name,
775
+ include_overrides,
776
+ raise_on_missing,
777
+ as_str,
778
+ )
779
+ if key in self._config_cache:
780
+ return self._config_cache[key]
781
+
524
782
  if name not in self._all_keys:
525
783
  raise ConfigUnknownItemError(name=name)
526
784
 
527
785
  elif name in self._meta_data:
528
- val = self._meta_data[name]
786
+ val = cast("dict", self._meta_data)[name]
529
787
 
530
788
  elif include_overrides and name in self._overrides:
531
789
  val = self._overrides[name]
@@ -538,7 +796,7 @@ class Config:
538
796
  val = default_value
539
797
 
540
798
  elif name in self._modified_keys:
541
- val = self._modified_keys[name]
799
+ val = cast("dict", self._modified_keys)[name]
542
800
 
543
801
  elif name in self._configurable_keys:
544
802
  val = self._file.get_config_item(
@@ -557,74 +815,121 @@ class Config:
557
815
  else:
558
816
  val = str(val)
559
817
 
818
+ if self._use_cache:
819
+ self._config_cache[key] = val
820
+
560
821
  return val
561
822
 
562
- def _parse_JSON(self, name, value):
823
+ def _parse_JSON(self, name: str, value: str) -> Any:
563
824
  try:
564
- value = json.loads(value)
825
+ return json.loads(value)
565
826
  except json.decoder.JSONDecodeError as err:
566
827
  raise ConfigChangeInvalidJSONError(name=name, json_str=value, err=err)
567
- return value
568
828
 
569
- def _set(self, name, value, is_json=False, callback=True, quiet=False):
829
+ @overload
830
+ def _set(
831
+ self, name: str, value: str, *, is_json: Literal[True], callback=True, quiet=False
832
+ ) -> None:
833
+ ...
834
+
835
+ @overload
836
+ def _set(
837
+ self,
838
+ name: str,
839
+ value: Any,
840
+ *,
841
+ is_json: Literal[False] = False,
842
+ callback=True,
843
+ quiet=False,
844
+ ) -> None:
845
+ ...
846
+
847
+ def _set(
848
+ self, name: str, value, *, is_json=False, callback=True, quiet=False
849
+ ) -> None:
850
+ """
851
+ Set a configuration item.
852
+ """
853
+ if self._use_cache:
854
+ raise ConfigReadOnlyError()
855
+
570
856
  if name not in self._configurable_keys:
571
857
  raise ConfigNonConfigurableError(name=name)
572
- else:
573
- if is_json:
574
- value = self._parse_JSON(name, value)
575
- current_val = self._get(name)
576
- callback_val = self._get_callback_value(name, value)
577
- file_val_raw = self._file.get_config_item(self._config_key, name)
578
- file_val = self._get_callback_value(name, file_val_raw)
579
-
580
- if callback_val != current_val:
581
- was_in_modified = False
582
- was_in_unset = False
583
- prev_modified_val = None
584
- modified_updated = False
585
-
586
- if name in self._modified_keys:
587
- was_in_modified = True
588
- prev_modified_val = self._modified_keys[name]
589
-
590
- if name in self._unset_keys:
591
- was_in_unset = True
592
- idx = self._unset_keys.index(name)
593
- self._unset_keys.pop(idx)
594
-
595
- if callback_val != file_val:
596
- self._modified_keys[name] = value
597
- modified_updated = True
858
+ if is_json:
859
+ value = self._parse_JSON(name, cast("str", value))
860
+ current_val = self._get(name)
861
+ callback_val = self._get_callback_value(name, value)
862
+ file_val = self._get_callback_value(
863
+ name, self._file.get_config_item(self._config_key, name)
864
+ )
598
865
 
599
- try:
600
- self._validate()
601
-
602
- if callback:
603
- for cb in self._set_callbacks.get(name, []):
604
- self._logger.debug(
605
- f"Invoking `config.set` callback for item {name!r}: {cb.__name__!r}"
606
- )
607
- cb(self, callback_val)
608
-
609
- except ConfigValidationError as err:
610
- # revert:
611
- if modified_updated:
612
- if was_in_modified:
613
- self._modified_keys[name] = prev_modified_val
614
- else:
615
- del self._modified_keys[name]
616
- if was_in_unset:
617
- self._unset_keys.append(name)
618
-
619
- raise ConfigChangeValidationError(name, validation_err=err) from None
866
+ if callback_val != current_val:
867
+ was_in_modified = False
868
+ was_in_unset = False
869
+ prev_modified_val = None
870
+ modified_updated = False
871
+ mk = cast("dict", self._modified_keys)
872
+
873
+ if name in self._modified_keys:
874
+ was_in_modified = True
875
+ prev_modified_val = mk[name]
876
+
877
+ if name in self._unset_keys:
878
+ was_in_unset = True
879
+ self._unset_keys.remove(name)
880
+
881
+ if callback_val != file_val:
882
+ mk[name] = value
883
+ modified_updated = True
884
+
885
+ try:
886
+ self._validate()
887
+
888
+ if callback:
889
+ for cb in self._set_callbacks.get(name, ()):
890
+ self._logger.debug(
891
+ f"Invoking `config.set` callback for item {name!r}: {cb.__name__!r}"
892
+ )
893
+ cb(self, callback_val)
894
+
895
+ except ConfigValidationError as err:
896
+ # revert:
897
+ if modified_updated:
898
+ if was_in_modified:
899
+ mk[name] = prev_modified_val
900
+ else:
901
+ del mk[name]
902
+ if was_in_unset:
903
+ self._unset_keys.add(name)
904
+
905
+ raise ConfigChangeValidationError(name, validation_err=err) from None
620
906
 
621
- self._logger.debug(
622
- f"Successfully set config item {name!r} to {callback_val!r}."
623
- )
624
- elif not quiet:
625
- print(f"value is already: {callback_val!r}")
907
+ self._logger.debug(
908
+ f"Successfully set config item {name!r} to {callback_val!r}."
909
+ )
910
+ elif not quiet:
911
+ print(f"value is already: {callback_val!r}")
626
912
 
627
- def set(self, path: str, value, is_json=False, quiet=False):
913
+ @overload
914
+ def set(
915
+ self,
916
+ path: str,
917
+ value: Any,
918
+ *,
919
+ is_json: Literal[False] = False,
920
+ quiet: bool = False,
921
+ ) -> None:
922
+ ...
923
+
924
+ @overload
925
+ def set(
926
+ self, path: str, value: str, *, is_json: Literal[True], quiet: bool = False
927
+ ) -> None:
928
+ ...
929
+
930
+ def set(
931
+ self, path: str, value: Any, *, is_json: bool = False, quiet: bool = False
932
+ ) -> None:
628
933
  """
629
934
  Set the value of a configuration item.
630
935
 
@@ -640,16 +945,15 @@ class Config:
640
945
  if is_json:
641
946
  value = self._parse_JSON(path, value)
642
947
 
643
- parts = path.split(".")
644
- name = parts[0]
948
+ name, *path_suffix = path.split(".")
645
949
  root = deepcopy(self._get(name, callback=False))
646
- if parts[1:]:
950
+ if path_suffix:
647
951
  if root is None:
648
952
  root = {}
649
- self.set(path=parts[0], value={}, quiet=True)
953
+ self.set(path=name, value={}, quiet=True)
650
954
  set_in_container(
651
955
  root,
652
- path=parts[1:],
956
+ path=path_suffix,
653
957
  value=value,
654
958
  ensure_path=True,
655
959
  cast_indices=True,
@@ -658,13 +962,13 @@ class Config:
658
962
  root = value
659
963
  self._set(name, root, quiet=quiet)
660
964
 
661
- def unset(self, name):
965
+ def unset(self, name: str, callback: bool = True) -> None:
662
966
  """
663
967
  Unset the value of a configuration item.
664
968
 
665
969
  Parameters
666
970
  ----------
667
- name: str
971
+ name:
668
972
  The name of the configuration item.
669
973
 
670
974
  Notes
@@ -676,48 +980,74 @@ class Config:
676
980
  if name in self._unset_keys or not self._file.is_item_set(self._config_key, name):
677
981
  raise ConfigItemAlreadyUnsetError(name=name)
678
982
 
679
- self._unset_keys.append(name)
983
+ self._unset_keys.add(name)
680
984
  try:
681
985
  self._validate()
986
+ if callback:
987
+ for cb in self._unset_callbacks.get(name, []):
988
+ self._logger.debug(
989
+ f"Invoking `config.unset` callback for item {name!r}: "
990
+ f"{cb.__name__!r}."
991
+ )
992
+ cb(self)
682
993
  except ConfigValidationError as err:
683
- self._unset_keys.pop()
994
+ self._unset_keys.remove(name)
684
995
  raise ConfigChangeValidationError(name, validation_err=err) from None
685
996
 
997
+ @overload
686
998
  def get(
687
999
  self,
688
- path,
689
- callback=True,
690
- copy=False,
691
- ret_root=False,
692
- ret_parts=False,
693
- default=None,
694
- ):
1000
+ path: str,
1001
+ *,
1002
+ callback: bool = True,
1003
+ copy: bool = False,
1004
+ ret_root_and_parts: Literal[False] = False,
1005
+ default: Any | None = None,
1006
+ ) -> Any:
1007
+ ...
1008
+
1009
+ @overload
1010
+ def get(
1011
+ self,
1012
+ path: str,
1013
+ *,
1014
+ callback: bool = True,
1015
+ copy: bool = False,
1016
+ ret_root_and_parts: Literal[True],
1017
+ default: Any | None = None,
1018
+ ) -> tuple[Any, Any, list[str]]:
1019
+ ...
1020
+
1021
+ def get(
1022
+ self,
1023
+ path: str,
1024
+ *,
1025
+ callback: bool = True,
1026
+ copy: bool = False,
1027
+ ret_root_and_parts: bool = False,
1028
+ default: Any | None = None,
1029
+ ) -> Any:
695
1030
  """
696
1031
  Get the value of a configuration item.
697
1032
 
698
1033
  Parameters
699
1034
  ----------
700
- path: str
1035
+ path:
701
1036
  The name of or path to the configuration item.
702
1037
  """
703
- parts = path.split(".")
704
- root = deepcopy(self._get(parts[0], callback=callback))
1038
+ name, *suffix = parts = path.split(".")
1039
+ root = deepcopy(self._get(name, callback=callback))
705
1040
  try:
706
- out = get_in_container(root, parts[1:], cast_indices=True)
1041
+ out = get_in_container(root, suffix, cast_indices=True)
707
1042
  except KeyError:
708
1043
  out = default
709
1044
  if copy:
710
1045
  out = deepcopy(out)
711
- if not (ret_root or ret_parts):
1046
+ if not ret_root_and_parts:
712
1047
  return out
713
- ret = [out]
714
- if ret_root:
715
- ret += [root]
716
- if ret_parts:
717
- ret += [parts]
718
- return tuple(ret)
1048
+ return out, root, parts
719
1049
 
720
- def append(self, path, value, is_json=False):
1050
+ def append(self, path: str, value, *, is_json: bool = False) -> None:
721
1051
  """
722
1052
  Append a value to a list-like configuration item.
723
1053
 
@@ -733,8 +1063,7 @@ class Config:
733
1063
 
734
1064
  existing, root, parts = self.get(
735
1065
  path,
736
- ret_root=True,
737
- ret_parts=True,
1066
+ ret_root_and_parts=True,
738
1067
  callback=False,
739
1068
  default=[],
740
1069
  )
@@ -756,7 +1085,7 @@ class Config:
756
1085
  root = new
757
1086
  self._set(parts[0], root)
758
1087
 
759
- def prepend(self, path, value, is_json=False):
1088
+ def prepend(self, path: str, value, *, is_json: bool = False) -> None:
760
1089
  """
761
1090
  Prepend a value to a list-like configuration item.
762
1091
 
@@ -771,7 +1100,7 @@ class Config:
771
1100
  value = self._parse_JSON(path, value)
772
1101
 
773
1102
  existing, root, parts = self.get(
774
- path, ret_root=True, ret_parts=True, callback=False, default=[]
1103
+ path, ret_root_and_parts=True, callback=False, default=[]
775
1104
  )
776
1105
 
777
1106
  try:
@@ -791,7 +1120,7 @@ class Config:
791
1120
  root = new
792
1121
  self._set(parts[0], root)
793
1122
 
794
- def pop(self, path, index):
1123
+ def pop(self, path: str, index) -> None:
795
1124
  """
796
1125
  Remove a value from a specified index of a list-like configuration item.
797
1126
 
@@ -802,11 +1131,9 @@ class Config:
802
1131
  index: int
803
1132
  Where to remove the value from. 0 for the first item, -1 for the last.
804
1133
  """
805
-
806
1134
  existing, root, parts = self.get(
807
1135
  path,
808
- ret_root=True,
809
- ret_parts=True,
1136
+ ret_root_and_parts=True,
810
1137
  callback=False,
811
1138
  default=[],
812
1139
  )
@@ -832,8 +1159,9 @@ class Config:
832
1159
  root = new
833
1160
  self._set(parts[0], root)
834
1161
 
835
- def update(self, path: str, value, is_json=False):
836
- """Update a map-like configuration item.
1162
+ def update(self, path: str, value, *, is_json: bool = False) -> None:
1163
+ """
1164
+ Update a map-like configuration item.
837
1165
 
838
1166
  Parameters
839
1167
  ----------
@@ -842,15 +1170,13 @@ class Config:
842
1170
  value: dict
843
1171
  A dictionary to merge in.
844
1172
  """
845
-
846
1173
  if is_json:
847
1174
  value = self._parse_JSON(path, value)
848
1175
 
849
1176
  val_mod, root, parts = self.get(
850
1177
  path,
851
1178
  copy=True,
852
- ret_root=True,
853
- ret_parts=True,
1179
+ ret_root_and_parts=True,
854
1180
  callback=False,
855
1181
  default={},
856
1182
  )
@@ -872,20 +1198,22 @@ class Config:
872
1198
  root = val_mod
873
1199
  self._set(parts[0], root)
874
1200
 
875
- def save(self):
1201
+ def save(self) -> None:
876
1202
  """Save any modified/unset configuration items into the file."""
877
1203
  if not self._modified_keys and not self._unset_keys:
878
1204
  print("No modifications to save!")
879
1205
  else:
880
1206
  self._file.save()
881
1207
 
882
- def get_configurable(self):
1208
+ def get_configurable(self) -> Sequence[str]:
883
1209
  """Get a list of all configurable keys."""
884
1210
  return self._configurable_keys
885
1211
 
886
- def _get_user_id(self):
887
- """Retrieve (and set if non-existent) a unique user ID that is independent of the
888
- config directory."""
1212
+ def _get_user_id(self) -> tuple[str, Path]:
1213
+ """
1214
+ Retrieve (and set if non-existent) a unique user ID that is independent of the
1215
+ config directory.
1216
+ """
889
1217
 
890
1218
  uid_file_path = self._app.user_data_dir.joinpath("user_id.txt")
891
1219
  if not uid_file_path.exists():
@@ -898,12 +1226,12 @@ class Config:
898
1226
 
899
1227
  return uid, uid_file_path
900
1228
 
901
- def reset(self):
1229
+ def reset(self) -> None:
902
1230
  """Reset to the default configuration."""
903
- self._logger.info(f"Resetting config file to defaults.")
1231
+ self._logger.info("Resetting config file to defaults.")
904
1232
  self._app.reset_config()
905
1233
 
906
- def add_scheduler(self, scheduler, **defaults):
1234
+ def add_scheduler(self, scheduler: str, **defaults) -> None:
907
1235
  """
908
1236
  Add a scheduler.
909
1237
  """
@@ -912,7 +1240,7 @@ class Config:
912
1240
  return
913
1241
  self.update(f"schedulers.{scheduler}.defaults", defaults)
914
1242
 
915
- def add_shell(self, shell, **defaults):
1243
+ def add_shell(self, shell: str, **defaults) -> None:
916
1244
  """
917
1245
  Add a shell.
918
1246
  """
@@ -923,7 +1251,7 @@ class Config:
923
1251
  self.add_scheduler("direct_posix")
924
1252
  self.update(f"shells.{shell}.defaults", defaults)
925
1253
 
926
- def add_shell_WSL(self, **defaults):
1254
+ def add_shell_WSL(self, **defaults) -> None:
927
1255
  """
928
1256
  Add shell with WSL prefix.
929
1257
  """
@@ -931,8 +1259,11 @@ class Config:
931
1259
  defaults["WSL_executable"] = "wsl.exe"
932
1260
  self.add_shell("wsl", **defaults)
933
1261
 
934
- def import_from_file(self, file_path, rename=True, make_new=False):
935
- """Import config items from a (remote or local) YAML file. Existing config items
1262
+ def import_from_file(
1263
+ self, file_path: Path | str, *, rename=True, make_new=False
1264
+ ) -> None:
1265
+ """
1266
+ Import config items from a (remote or local) YAML file. Existing config items
936
1267
  of the same names will be overwritten.
937
1268
 
938
1269
  Parameters
@@ -947,17 +1278,12 @@ class Config:
947
1278
  If True, add the config items as a new config, rather than modifying the
948
1279
  current config. The name of the new config will be the stem of the file
949
1280
  specified in `file_path`.
950
-
951
1281
  """
952
-
953
1282
  self._logger.debug(f"import from file: {file_path!r}")
954
1283
 
955
1284
  console = Console()
956
- status = console.status(f"Importing config from file {file_path!r}...")
957
- status.start()
958
-
959
- try:
960
- file_dat = read_YAML_file(file_path)
1285
+ with console.status(f"Importing config from file {file_path!r}...") as status:
1286
+ file_dat: DefaultConfiguration = read_YAML_file(file_path)
961
1287
  if rename or make_new:
962
1288
  file_stem = Path(file_path).stem
963
1289
  name = file_stem
@@ -992,72 +1318,102 @@ class Config:
992
1318
  )
993
1319
 
994
1320
  new_invoc = file_dat.get("invocation")
995
- new_config = file_dat.get("config")
1321
+ new_config = file_dat.get("config", {})
996
1322
 
997
- if new_invoc:
1323
+ if new_invoc is not None:
998
1324
  status.update("Updating invocation details...")
999
1325
  config_key = file_stem if (make_new or rename) else self._config_key
1000
1326
  obj._file.update_invocation(
1001
1327
  config_key=config_key,
1002
1328
  environment_setup=new_invoc.get("environment_setup"),
1003
- match=new_invoc.get("match"),
1329
+ match=new_invoc.get("match", {}),
1004
1330
  )
1005
1331
 
1006
1332
  # sort in reverse so "schedulers" and "shells" are set before
1007
1333
  # "default_scheduler" and "default_shell" which might reference the former:
1008
- new_config = dict(sorted(new_config.items(), reverse=True))
1009
- for k, v in new_config.items():
1334
+ for k, v in sorted(new_config.items(), reverse=True):
1010
1335
  status.update(f"Updating configurable item {k!r}")
1011
1336
  obj.set(k, value=v, quiet=True)
1012
1337
 
1013
1338
  obj.save()
1014
1339
 
1015
- except Exception:
1016
- status.stop()
1017
- raise
1018
-
1019
- status.stop()
1020
1340
  print(f"Config {name!r} updated.")
1021
1341
 
1022
- def init(self, known_name: str, path: Optional[str] = None):
1342
+ def init(self, known_name: str, path: str | None = None) -> None:
1023
1343
  """Configure from a known importable config."""
1024
1344
  if not path:
1025
- path = self._options.default_known_configs_dir
1026
- if not path:
1345
+ if not (path := self._options.default_known_configs_dir):
1027
1346
  raise ValueError("Specify an `path` to search for known config files.")
1028
1347
  elif path == ".":
1029
1348
  path = str(Path(path).resolve())
1030
1349
 
1031
1350
  self._logger.debug(f"init with `path` = {path!r}")
1032
1351
 
1033
- fs = fsspec.open(path).fs
1352
+ fs: AbstractFileSystem = fsspec.open(path).fs
1034
1353
  local_path = f"{path}/" if isinstance(fs, LocalFileSystem) else ""
1035
1354
  files = fs.glob(f"{local_path}*.yaml") + fs.glob(f"{local_path}*.yml")
1036
1355
  self._logger.debug(f"All YAML files found in file-system {fs!r}: {files}")
1037
1356
 
1038
- files = [i for i in files if Path(i).stem.startswith(known_name)]
1039
- if not files:
1357
+ if not (files := [i for i in files if Path(i).stem.startswith(known_name)]):
1040
1358
  print(f"No configuration-import files found matching name {known_name!r}.")
1041
1359
  return
1042
1360
 
1043
1361
  print(f"Found configuration-import files: {files!r}")
1044
- for i in files:
1045
- self.import_from_file(file_path=i, make_new=True)
1362
+ for file_i in files:
1363
+ self.import_from_file(file_path=file_i, make_new=True)
1046
1364
 
1047
- print(f"imports complete")
1365
+ print("imports complete")
1048
1366
  # if current config is named "default", rename machine to DEFAULT_CONFIG:
1049
1367
  if self._config_key == "default":
1050
1368
  self.set("machine", "DEFAULT_MACHINE")
1051
1369
  self.save()
1052
1370
 
1053
- def set_github_demo_data_dir(self, sha):
1054
- """Set the `demo_data_dir` item, to an fsspec Github URL.
1371
+ def set_github_demo_data_dir(self, sha: str) -> None:
1372
+ """
1373
+ Set the `demo_data_dir` item, to an fsspec Github URL.
1055
1374
 
1056
1375
  We use this (via the CLI) when testing the frozen app on Github, because, by
1057
1376
  default, the SHA is set to the current version tag, which might not include recent
1058
1377
  changes to the demo data.
1059
-
1060
1378
  """
1061
- path = "/".join(self._app.demo_data_dir.split("."))
1062
- url = self._app._get_github_url(sha=sha, path=path)
1063
- self.set("demo_data_dir", url)
1379
+ assert self._app.demo_data_dir is not None
1380
+ self.set(
1381
+ "demo_data_dir",
1382
+ self._app._get_github_url(
1383
+ sha=sha, path=self._app.demo_data_dir.replace(".", "/")
1384
+ ),
1385
+ )
1386
+
1387
+ @contextlib.contextmanager
1388
+ def cached_config(self) -> Iterator[None]:
1389
+ try:
1390
+ self._use_cache = True
1391
+ yield
1392
+ finally:
1393
+ self._use_cache = False
1394
+ self._config_cache = {} # reset the cache
1395
+
1396
+ def _is_set(self, name: str) -> bool:
1397
+ """Check if a (non-metadata) config item is set."""
1398
+ if name in self._unset_keys:
1399
+ return False
1400
+ elif name in self._modified_keys:
1401
+ return True
1402
+ else:
1403
+ return self._file.is_item_set(self._config_key, name)
1404
+
1405
+ @contextlib.contextmanager
1406
+ def _with_updates(self, updates: dict[str, Any]) -> Iterator[None]:
1407
+ # need to run callbacks for unsetting?
1408
+ prev_unset = copy.deepcopy(self._unset_keys)
1409
+ prev_modified = copy.deepcopy(self._modified_keys)
1410
+ to_unset = []
1411
+ try:
1412
+ for k, v in updates.items():
1413
+ if not self._is_set(k):
1414
+ to_unset.append(k)
1415
+ self.set(k, v)
1416
+ yield
1417
+ finally:
1418
+ self._unset_keys = prev_unset
1419
+ self._modified_keys = prev_modified