openjd-cli 0.7.4__tar.gz → 0.7.6__tar.gz

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 (34) hide show
  1. {openjd_cli-0.7.4 → openjd_cli-0.7.6}/PKG-INFO +3 -3
  2. openjd_cli-0.7.6/_version.py +24 -0
  3. {openjd_cli-0.7.4 → openjd_cli-0.7.6}/hatch.toml +0 -3
  4. {openjd_cli-0.7.4 → openjd_cli-0.7.6}/hatch_version_hook.py +0 -1
  5. {openjd_cli-0.7.4 → openjd_cli-0.7.6}/pyproject.toml +5 -2
  6. {openjd_cli-0.7.4 → openjd_cli-0.7.6}/src/openjd/__main__.py +0 -1
  7. {openjd_cli-0.7.4 → openjd_cli-0.7.6}/src/openjd/cli/_check/_check_command.py +4 -1
  8. {openjd_cli-0.7.4 → openjd_cli-0.7.6}/src/openjd/cli/_common/_extensions.py +12 -3
  9. {openjd_cli-0.7.4 → openjd_cli-0.7.6}/src/openjd/cli/_common/_validation_utils.py +17 -3
  10. {openjd_cli-0.7.4 → openjd_cli-0.7.6}/src/openjd/cli/_create_argparser.py +0 -1
  11. {openjd_cli-0.7.4 → openjd_cli-0.7.6}/src/openjd/cli/_run/_local_session/_actions.py +41 -4
  12. {openjd_cli-0.7.4 → openjd_cli-0.7.6}/src/openjd/cli/_run/_local_session/_session_manager.py +119 -17
  13. {openjd_cli-0.7.4 → openjd_cli-0.7.6}/src/openjd/cli/_run/_run_command.py +25 -4
  14. openjd_cli-0.7.6/src/openjd/cli/_version.py +24 -0
  15. openjd_cli-0.7.4/_version.py +0 -34
  16. openjd_cli-0.7.4/src/openjd/cli/_version.py +0 -34
  17. {openjd_cli-0.7.4 → openjd_cli-0.7.6}/.gitignore +0 -0
  18. {openjd_cli-0.7.4 → openjd_cli-0.7.6}/LICENSE +0 -0
  19. {openjd_cli-0.7.4 → openjd_cli-0.7.6}/NOTICE +0 -0
  20. {openjd_cli-0.7.4 → openjd_cli-0.7.6}/README.md +0 -0
  21. {openjd_cli-0.7.4 → openjd_cli-0.7.6}/src/openjd/cli/__init__.py +0 -0
  22. {openjd_cli-0.7.4 → openjd_cli-0.7.6}/src/openjd/cli/_check/__init__.py +0 -0
  23. {openjd_cli-0.7.4 → openjd_cli-0.7.6}/src/openjd/cli/_common/__init__.py +0 -0
  24. {openjd_cli-0.7.4 → openjd_cli-0.7.6}/src/openjd/cli/_common/_job_from_template.py +0 -0
  25. {openjd_cli-0.7.4 → openjd_cli-0.7.6}/src/openjd/cli/_run/__init__.py +0 -0
  26. {openjd_cli-0.7.4 → openjd_cli-0.7.6}/src/openjd/cli/_run/_help_formatter.py +0 -0
  27. {openjd_cli-0.7.4 → openjd_cli-0.7.6}/src/openjd/cli/_run/_local_session/__init__.py +0 -0
  28. {openjd_cli-0.7.4 → openjd_cli-0.7.6}/src/openjd/cli/_run/_local_session/_logs.py +0 -0
  29. {openjd_cli-0.7.4 → openjd_cli-0.7.6}/src/openjd/cli/_schema/__init__.py +0 -0
  30. {openjd_cli-0.7.4 → openjd_cli-0.7.6}/src/openjd/cli/_schema/_schema_command.py +0 -0
  31. {openjd_cli-0.7.4 → openjd_cli-0.7.6}/src/openjd/cli/_summary/__init__.py +0 -0
  32. {openjd_cli-0.7.4 → openjd_cli-0.7.6}/src/openjd/cli/_summary/_summary_command.py +0 -0
  33. {openjd_cli-0.7.4 → openjd_cli-0.7.6}/src/openjd/cli/_summary/_summary_output.py +0 -0
  34. {openjd_cli-0.7.4 → openjd_cli-0.7.6}/src/openjd/cli/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openjd-cli
3
- Version: 0.7.4
3
+ Version: 0.7.6
4
4
  Summary: Provides a command-line interface for working with Open Job Description templates.
5
5
  Project-URL: Homepage, https://github.com/OpenJobDescription/openjd-cli
6
6
  Project-URL: Source, https://github.com/OpenJobDescription/openjd-cli
@@ -22,8 +22,8 @@ Classifier: Programming Language :: Python :: 3.10
22
22
  Classifier: Programming Language :: Python :: 3.11
23
23
  Classifier: Programming Language :: Python :: 3.12
24
24
  Requires-Python: >=3.9
25
- Requires-Dist: openjd-model<0.9,>=0.8
26
- Requires-Dist: openjd-sessions<0.11,>=0.10.3
25
+ Requires-Dist: openjd-model<0.12,>=0.9
26
+ Requires-Dist: openjd-sessions<0.11,>=0.10.11
27
27
  Description-Content-Type: text/markdown
28
28
 
29
29
  ## Open Job Description - CLI
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.7.6'
22
+ __version_tuple__ = version_tuple = (0, 7, 6)
23
+
24
+ __commit_id__ = commit_id = None
@@ -23,9 +23,6 @@ lint = [
23
23
  [[envs.all.matrix]]
24
24
  python = ["3.9", "3.10", "3.11", "3.12", "3.13"]
25
25
 
26
- [envs.codebuild.scripts]
27
- build = "hatch build"
28
-
29
26
  [envs.release]
30
27
  detached = true
31
28
 
@@ -7,7 +7,6 @@ from dataclasses import dataclass
7
7
  from hatchling.builders.hooks.plugin.interface import BuildHookInterface
8
8
  from typing import Any, Optional
9
9
 
10
-
11
10
  _logger = logging.Logger(__name__, logging.INFO)
12
11
  _stdout_handler = logging.StreamHandler(sys.stdout)
13
12
  _stdout_handler.addFilter(lambda record: record.levelno <= logging.INFO)
@@ -29,8 +29,11 @@ classifiers = [
29
29
  "Intended Audience :: End Users/Desktop"
30
30
  ]
31
31
  dependencies = [
32
- "openjd-sessions >= 0.10.3,< 0.11",
33
- "openjd-model >= 0.8,< 0.9"
32
+ # 0.10.11 is the first release with `step_name` on both Session.run_task and
33
+ # Session.enter_environment (RFC 0007/0008). Below it, run_task() raises
34
+ # TypeError on every task run, since this CLI passes the keyword unconditionally.
35
+ "openjd-sessions >= 0.10.11,< 0.11",
36
+ "openjd-model >= 0.9,< 0.12"
34
37
  ]
35
38
 
36
39
  [project.urls]
@@ -2,5 +2,4 @@
2
2
 
3
3
  from .cli._create_argparser import main
4
4
 
5
-
6
5
  main()
@@ -41,7 +41,10 @@ def do_check(args: Namespace) -> OpenJDCliResult:
41
41
  if TemplateSpecificationVersion.is_job_template(template_version):
42
42
  decode_job_template(template=template_object, supported_extensions=extensions)
43
43
  elif TemplateSpecificationVersion.is_environment_template(template_version):
44
- decode_environment_template(template=template_object)
44
+ # Pass `extensions` so env templates can declare e.g.
45
+ # WRAP_ACTIONS (RFC 0008) — without this the model rejects
46
+ # the extension declaration even though the CLI accepts it.
47
+ decode_environment_template(template=template_object, supported_extensions=extensions)
45
48
  else:
46
49
  return OpenJDCliResult(
47
50
  status="error",
@@ -3,14 +3,23 @@
3
3
  from argparse import ArgumentParser
4
4
  from typing import Optional
5
5
 
6
- # This is the list of Open Job Description extensions with implemented support
7
- SUPPORTED_EXTENSIONS = ["TASK_CHUNKING", "REDACTED_ENV_VARS"]
6
+ # This is the list of Open Job Description extensions with implemented support.
7
+ # EXPR (RFCs 0005/0006) and WRAP_ACTIONS (RFC 0008) are added so the CLI can run
8
+ # templates that declare them. WRAP_ACTIONS implies EXPR; the model library
9
+ # handles that implication when it parses the template.
10
+ SUPPORTED_EXTENSIONS = [
11
+ "TASK_CHUNKING",
12
+ "REDACTED_ENV_VARS",
13
+ "FEATURE_BUNDLE_1",
14
+ "EXPR",
15
+ "WRAP_ACTIONS",
16
+ ]
8
17
 
9
18
 
10
19
  def add_extensions_argument(run_parser: ArgumentParser):
11
20
  run_parser.add_argument(
12
21
  "--extensions",
13
- help=f"A comma-separated list of Open Job Description extension names to enable. Defaults to all that are implemented: {','.join(SUPPORTED_EXTENSIONS)}",
22
+ help=f"A comma-separated list of Open Job Description extension names that the CLI will accept from job templates. Templates declare which extensions they use. Defaults to all that are implemented: {','.join(SUPPORTED_EXTENSIONS)}",
14
23
  )
15
24
 
16
25
 
@@ -1,6 +1,6 @@
1
1
  # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
2
 
3
- from typing import Any
3
+ from typing import Any, Optional
4
4
  from pathlib import Path
5
5
 
6
6
  from openjd.model import (
@@ -70,15 +70,29 @@ def read_job_template(template_file: Path, *, supported_extensions: list[str]) -
70
70
  return template
71
71
 
72
72
 
73
- def read_environment_template(template_file: Path) -> EnvironmentTemplate:
73
+ def read_environment_template(
74
+ template_file: Path,
75
+ *,
76
+ supported_extensions: Optional[list[str]] = None,
77
+ ) -> EnvironmentTemplate:
74
78
  """Open a JSON or YAML-formatted file and attempt to parse it into an EnvironmentTemplate object.
75
79
  Raises a RuntimeError if the file doesn't exist or can't be opened, and raises a
76
80
  DecodeValidationError if its contents can't be parsed into a valid EnvironmentTemplate.
81
+
82
+ Environment templates may declare extensions just like job templates do
83
+ (e.g. ``WRAP_ACTIONS`` for RFC 0008's wrap hooks). Pass the CLI's allow-list
84
+ via ``supported_extensions`` so the model accepts those declarations;
85
+ omitting the argument preserves the legacy behavior of accepting only the
86
+ default model surface.
77
87
  """
78
88
  # Raises RuntimeError
79
89
  template_object = read_template(template_file)
80
90
 
91
+ decode_kwargs: dict[str, list[str]] = {}
92
+ if supported_extensions is not None:
93
+ decode_kwargs["supported_extensions"] = supported_extensions
94
+
81
95
  # Raises: DecodeValidationError
82
- template = decode_environment_template(template=template_object)
96
+ template = decode_environment_template(template=template_object, **decode_kwargs)
83
97
 
84
98
  return template
@@ -14,7 +14,6 @@ from ._summary import populate_argparser as populate_summary_subparser
14
14
  from ._run import populate_argparser as populate_run_subparser
15
15
  from ._schema import populate_argparser as populate_schema_subparser
16
16
 
17
-
18
17
  # Our CLI subcommand construction requires that all leaf subcommands define a default
19
18
  # 'func' property which is a Callable[[],None] that implements the subcommand.
20
19
  # After parsing, we call that `func` argument of the resulting args object.
@@ -1,6 +1,7 @@
1
1
  # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
2
 
3
3
  from enum import Enum
4
+ from typing import Any, Optional
4
5
 
5
6
  from openjd.model import Step, TaskParameterSet
6
7
  from openjd.model.v2023_09 import Environment
@@ -47,7 +48,11 @@ class RunTaskAction(SessionAction):
47
48
 
48
49
  def run(self):
49
50
  self._session.run_task(
50
- step_script=self._step.script, task_parameter_values=self._parameters
51
+ step_script=self._step.script,
52
+ task_parameter_values=self._parameters,
53
+ # RFC 0008: the step name feeds the WrappedStep.Name template
54
+ # variable inside an active onWrapTaskRun hook.
55
+ step_name=self._step.name,
51
56
  )
52
57
 
53
58
  def __str__(self):
@@ -58,14 +63,46 @@ class RunTaskAction(SessionAction):
58
63
  class EnterEnvironmentAction(SessionAction):
59
64
  _environment: Environment
60
65
  _id: str
61
-
62
- def __init__(self, session: Session, environment: Environment, env_id: str):
66
+ _extra_let_bindings: Optional[list[str]]
67
+ _step_name: Optional[str]
68
+
69
+ def __init__(
70
+ self,
71
+ session: Session,
72
+ environment: Environment,
73
+ env_id: str,
74
+ extra_let_bindings: Optional[list[str]] = None,
75
+ step_name: Optional[str] = None,
76
+ ):
63
77
  super(EnterEnvironmentAction, self).__init__(session)
64
78
  self._environment = environment
65
79
  self._id = env_id
80
+ # RFC 0007: a step's environments are entered with the step-level
81
+ # `let` bindings so their variables/actions can reference them.
82
+ self._extra_let_bindings = extra_let_bindings
83
+ # RFC 0007 §7.3.1 (EXPR): the owning step's name seeds Step.Name for
84
+ # a step environment's `let` bindings, variables, and actions. Only
85
+ # step-environment enters carry a step name; job/external enters
86
+ # leave it None.
87
+ self._step_name = step_name
66
88
 
67
89
  def run(self):
68
- self._session.enter_environment(environment=self._environment, identifier=self._id)
90
+ # Both keywords are guaranteed by this package's `openjd-sessions`
91
+ # floor (>= 0.10.11), so neither is feature-detected. They are still
92
+ # only forwarded when they carry something: a step with no `let`
93
+ # bindings means "no extra bindings", and job/external environment
94
+ # enters have no owning step, so `Step.Name` must stay undefined for
95
+ # them rather than being seeded with None.
96
+ optional_kwargs: dict[str, Any] = {}
97
+ if self._extra_let_bindings:
98
+ optional_kwargs["extra_let_bindings"] = self._extra_let_bindings
99
+ if self._step_name is not None:
100
+ optional_kwargs["step_name"] = self._step_name
101
+ self._session.enter_environment(
102
+ environment=self._environment,
103
+ identifier=self._id,
104
+ **optional_kwargs,
105
+ )
69
106
 
70
107
  def __str__(self):
71
108
  return f"Enter Environment '{self._environment.name}'"
@@ -8,6 +8,7 @@ from types import FrameType, TracebackType
8
8
  from signal import signal, SIGINT, SIGTERM, SIG_DFL
9
9
  from itertools import islice
10
10
  from datetime import datetime, timedelta, timezone
11
+ from math import isfinite
11
12
 
12
13
  from ._actions import (
13
14
  EnterEnvironmentAction,
@@ -49,6 +50,50 @@ class LocalSessionFailed(RuntimeError):
49
50
  super().__init__(f"Action failed: {failed_action}")
50
51
 
51
52
 
53
+ def _calculate_adaptive_chunk_size(
54
+ *,
55
+ current_chunk_size: int,
56
+ completed_task_count: int,
57
+ completed_task_duration: float,
58
+ target_runtime_seconds: float,
59
+ ) -> Optional[int]:
60
+ """The adaptive-chunking size estimate, or ``None`` to defer adjusting.
61
+
62
+ Implements steps 3-7 of the adaptive chunking algorithm in the Open Job
63
+ Description CLI specification (``specs/cli/run.md`` in openjd-rs): if the
64
+ cumulative duration is zero or non-finite, keep the current chunk size and
65
+ wait for a measurable sample.
66
+
67
+ Deferring matters because a zero ``duration_per_task`` makes the ideal size
68
+ unrepresentable. The two implementations fail differently without this
69
+ guard: the Rust CLI produces ``inf``, which saturates to a huge chunk size,
70
+ while Python raises ``ZeroDivisionError`` out of the run. Neither is
71
+ reachable through a normal ``openjd run`` today -- ``run_task`` always
72
+ spawns and waits on a real subprocess, so a measured chunk duration of
73
+ exactly ``0.0`` would need that round trip to complete inside one
74
+ ``perf_counter`` tick (~42 ns here) -- so this is spec conformance and
75
+ hardening rather than a fix for an observed failure.
76
+
77
+ Extracted as a module-level function, mirroring openjd-rs's
78
+ ``calculate_adaptive_chunk_size``, so the deferral is unit-testable without
79
+ needing to provoke an unreachable timing condition end to end.
80
+ """
81
+ if (
82
+ completed_task_count <= 0
83
+ or completed_task_duration <= 0.0
84
+ or not isfinite(completed_task_duration)
85
+ ):
86
+ return None
87
+
88
+ duration_per_task = completed_task_duration / completed_task_count
89
+ adaptive_chunk_size = target_runtime_seconds / duration_per_task
90
+ if completed_task_count < 10 and adaptive_chunk_size > current_chunk_size:
91
+ # When we have data about only a few tasks, gradually blend in the new
92
+ # estimate instead of cutting over immediately.
93
+ adaptive_chunk_size = 0.75 * current_chunk_size + 0.25 * adaptive_chunk_size
94
+ return max(int(adaptive_chunk_size), 1)
95
+
96
+
52
97
  class LocalSession:
53
98
  """
54
99
  A class to manage a `Session` object from the `sessions` module,
@@ -102,6 +147,8 @@ class LocalSession:
102
147
  self._openjd_session = Session(
103
148
  session_id=self.session_id,
104
149
  job_parameter_values=job_parameter_values,
150
+ # RFC 0007 §7.3.1 (EXPR): seeds the Job.Name template variable.
151
+ job_name=str(job.name),
105
152
  path_mapping_rules=self._path_mapping_rules,
106
153
  callback=self._action_callback,
107
154
  retain_working_dir=retain_working_dir,
@@ -197,7 +244,14 @@ class LocalSession:
197
244
  LOG.info("Interruption signal recieved.")
198
245
  self.cancel()
199
246
 
200
- def run_environment_enters(self, environments: Optional[list[Any]], type: EnvironmentType):
247
+ def run_environment_enters(
248
+ self,
249
+ environments: Optional[list[Any]],
250
+ type: EnvironmentType,
251
+ *,
252
+ extra_let_bindings: Optional[list[str]] = None,
253
+ step_name: Optional[str] = None,
254
+ ):
201
255
  """Enter one or more environments in the session."""
202
256
  if environments is None:
203
257
  return
@@ -211,10 +265,43 @@ class LocalSession:
211
265
  env_id = f"{type.name} - {env.name}"
212
266
  self._action_ended.clear()
213
267
  self._current_action = EnterEnvironmentAction(
214
- session=self._openjd_session, environment=env, env_id=env_id
268
+ session=self._openjd_session,
269
+ environment=env,
270
+ env_id=env_id,
271
+ # RFC 0007: a step's environments see the step-level `let`
272
+ # bindings.
273
+ extra_let_bindings=extra_let_bindings,
274
+ # RFC 0007 §7.3.1 (EXPR): a step's environments see Step.Name.
275
+ # Only step-environment enters carry a step name.
276
+ step_name=step_name,
215
277
  )
216
278
  self._environments_entered.append((type, env_id))
217
- self._current_action.run()
279
+ try:
280
+ self._current_action.run()
281
+ except (RuntimeError, ValueError) as exc:
282
+ # Session.enter_environment raises (rather than reporting
283
+ # through the action-status callback) when it rejects the
284
+ # environment up front — e.g. the RFC 0008 "at most one wrap
285
+ # environment" RuntimeError, or a ValueError from the extra
286
+ # `let` bindings — but it can also raise *after* registering
287
+ # the environment (e.g. an environment `variables` expression
288
+ # that fails to evaluate). Only when the session did NOT
289
+ # register the environment may we drop it from our entered
290
+ # list (cleanup must not try to exit it). If the session did
291
+ # register it, it must stay in our list so cleanup exits it —
292
+ # popping it here would skip its onExit and desynchronize us
293
+ # from the session's LIFO exit ordering check, masking the
294
+ # original error with "Must exit Environment X first".
295
+ if env_id not in self._openjd_session.environments_entered:
296
+ self._environments_entered.pop()
297
+ LOG.info(
298
+ msg=f"Open Job Description CLI: ERROR entering environment '{env.name}': {exc}",
299
+ extra={"session_id": self.session_id},
300
+ )
301
+ self.failed = True
302
+ self._failed_action = self._current_action
303
+ self._current_action = None
304
+ raise LocalSessionFailed(self._failed_action) from exc
218
305
  self._action_ended.wait()
219
306
  if self.failed:
220
307
  self._failed_action = self._current_action
@@ -300,27 +387,28 @@ class LocalSession:
300
387
  # Estimate a chunk size based on the statistics, and update the iterator. Note that this
301
388
  # logic is very simple, providing a good starting point that behaves reasonably for other implementations
302
389
  # to follow.
303
- duration_per_task = completed_task_duration / completed_task_count
304
- adaptive_chunk_size = target_runtime_seconds / duration_per_task
390
+ new_chunk_size = _calculate_adaptive_chunk_size(
391
+ current_chunk_size=task_parameters.chunks_default_task_count, # type: ignore
392
+ completed_task_count=completed_task_count,
393
+ completed_task_duration=completed_task_duration,
394
+ target_runtime_seconds=target_runtime_seconds,
395
+ )
396
+ # `None` means there is no measurable sample yet: keep the current chunk
397
+ # size and wait for one. Deliberately not `continue` -- the maximum-task
398
+ # countdown below must still run for this completed chunk.
305
399
  if (
306
- completed_task_count < 10
307
- and adaptive_chunk_size > task_parameters.chunks_default_task_count # type: ignore
400
+ new_chunk_size is not None
401
+ and new_chunk_size != task_parameters.chunks_default_task_count
308
402
  ):
309
- # When we have data about only a few tasks, gradually blend in the new estimate instead of cutting over immediately
310
- adaptive_chunk_size = (
311
- 0.75 * task_parameters.chunks_default_task_count + 0.25 * adaptive_chunk_size # type: ignore
312
- )
313
- adaptive_chunk_size = max(int(adaptive_chunk_size), 1)
314
- if adaptive_chunk_size != task_parameters.chunks_default_task_count:
315
403
  LOG.info(
316
404
  msg=f"Open Job Description CLI: Ran {completed_task_count} tasks in {timedelta(seconds=completed_task_duration)}, average {timedelta(seconds=completed_task_duration / completed_task_count)}",
317
405
  extra={"session_id": self.session_id},
318
406
  )
319
407
  LOG.info(
320
- msg=f"Open Job Description CLI: Adjusting chunk size from {task_parameters.chunks_default_task_count} to {adaptive_chunk_size}",
408
+ msg=f"Open Job Description CLI: Adjusting chunk size from {task_parameters.chunks_default_task_count} to {new_chunk_size}",
321
409
  extra={"session_id": self.session_id},
322
410
  )
323
- task_parameters.chunks_default_task_count = adaptive_chunk_size
411
+ task_parameters.chunks_default_task_count = new_chunk_size
324
412
 
325
413
  # If a maximum task count was specified, count them down
326
414
  if maximum_tasks and maximum_tasks > 0:
@@ -348,8 +436,22 @@ class LocalSession:
348
436
  if task_parameters is None:
349
437
  task_parameters = StepParameterSpaceIterator(space=step.parameterSpace)
350
438
 
351
- # Enter all the step environments
352
- self.run_environment_enters(step.stepEnvironments, EnvironmentType.STEP)
439
+ # Enter all the step environments. When the step defines step-level
440
+ # `let` bindings (RFC 0007), its environments are entered with them so
441
+ # their variables and actions can reference them.
442
+ # getattr guard: requires an openjd-model with Step.let on the
443
+ # instantiated Job (openjd-model PR #318+); collapse to plain
444
+ # `step.let` once the version pin floor guarantees it.
445
+ step_let_bindings = getattr(step, "let", None)
446
+ self.run_environment_enters(
447
+ step.stepEnvironments,
448
+ EnvironmentType.STEP,
449
+ extra_let_bindings=step_let_bindings or None,
450
+ # RFC 0007 §7.3.1 (EXPR): Step.Name is available to a step's
451
+ # environments (openjd-rs threads the step's resolved symbol
452
+ # table into enter_environment; this is the CLI counterpart).
453
+ step_name=step.name,
454
+ )
353
455
 
354
456
  try:
355
457
  # Run the tasks
@@ -338,6 +338,7 @@ def _run_local_session(
338
338
  Creates a Session object and listens for log messages to synchronously end the session.
339
339
  """
340
340
 
341
+ error_message = "Session ended with errors; see Task logs for details"
341
342
  try:
342
343
  start_seconds = time.perf_counter()
343
344
 
@@ -367,6 +368,14 @@ def _run_local_session(
367
368
  except LocalSessionFailed:
368
369
  duration = time.perf_counter() - start_seconds
369
370
  session = None
371
+ except (RuntimeError, ValueError) as exc:
372
+ # Exceptions raised by openjd.sessions from within session actions
373
+ # (e.g. the RFC 0008 "at most one wrap environment" RuntimeError)
374
+ # rather than reported through the action-status callback. Report
375
+ # them as a clean error result instead of a raw traceback.
376
+ duration = time.perf_counter() - start_seconds
377
+ session = None
378
+ error_message = f"Session ended with errors: {exc}"
370
379
 
371
380
  preserved_message: str = ""
372
381
  if retain_working_dir and session is not None:
@@ -377,7 +386,7 @@ def _run_local_session(
377
386
  if session is None or session.failed:
378
387
  return OpenJDRunResult(
379
388
  status="error",
380
- message="Session ended with errors; see Task logs for details" + preserved_message,
389
+ message=error_message + preserved_message,
381
390
  job_name=job.name,
382
391
  step_name=step_name,
383
392
  duration=duration,
@@ -415,7 +424,11 @@ def do_run(args: Namespace) -> OpenJDCliResult:
415
424
  filename = Path(env).expanduser()
416
425
  try:
417
426
  # Raises: RuntimeError, DecodeValidationError
418
- env_template = read_environment_template(filename)
427
+ # Pass `extensions` so env templates can declare e.g.
428
+ # WRAP_ACTIONS (RFC 0008) — without this they're parsed
429
+ # against the default model surface and reject the
430
+ # extension declaration.
431
+ env_template = read_environment_template(filename, supported_extensions=extensions)
419
432
  environments.append(env_template)
420
433
  except (RuntimeError, DecodeValidationError) as e:
421
434
  return OpenJDCliResult(status="error", message=str(e))
@@ -543,9 +556,17 @@ def do_run(args: Namespace) -> OpenJDCliResult:
543
556
  return OpenJDCliResult(status="error", message=str(rte))
544
557
 
545
558
  # Create a RevisionExtensions object with the default specification version and enabled extensions
546
- # We use the default v2023_09 since that's what we're currently supporting
559
+ # We use the default v2023_09 since that's what we're currently supporting.
560
+ # Extension behaviors activate only when declared by the templates being
561
+ # run (the job template and any external environment templates) — the
562
+ # CLI's --extensions list is what the CLI *accepts*, not what is enabled.
563
+ declared_extensions: list[str] = list(the_job.extensions or [])
564
+ for env_template in environments or []:
565
+ for ext_name in env_template.extensions or []:
566
+ if ext_name not in declared_extensions:
567
+ declared_extensions.append(ext_name)
547
568
  revision_extensions = RevisionExtensions(
548
- spec_rev=the_job.revision, supported_extensions=extensions
569
+ spec_rev=the_job.revision, supported_extensions=declared_extensions
549
570
  )
550
571
 
551
572
  return _run_local_session(
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.7.6'
22
+ __version_tuple__ = version_tuple = (0, 7, 6)
23
+
24
+ __commit_id__ = commit_id = None
@@ -1,34 +0,0 @@
1
- # file generated by setuptools-scm
2
- # don't change, don't track in version control
3
-
4
- __all__ = [
5
- "__version__",
6
- "__version_tuple__",
7
- "version",
8
- "version_tuple",
9
- "__commit_id__",
10
- "commit_id",
11
- ]
12
-
13
- TYPE_CHECKING = False
14
- if TYPE_CHECKING:
15
- from typing import Tuple
16
- from typing import Union
17
-
18
- VERSION_TUPLE = Tuple[Union[int, str], ...]
19
- COMMIT_ID = Union[str, None]
20
- else:
21
- VERSION_TUPLE = object
22
- COMMIT_ID = object
23
-
24
- version: str
25
- __version__: str
26
- __version_tuple__: VERSION_TUPLE
27
- version_tuple: VERSION_TUPLE
28
- commit_id: COMMIT_ID
29
- __commit_id__: COMMIT_ID
30
-
31
- __version__ = version = '0.7.4'
32
- __version_tuple__ = version_tuple = (0, 7, 4)
33
-
34
- __commit_id__ = commit_id = None
@@ -1,34 +0,0 @@
1
- # file generated by setuptools-scm
2
- # don't change, don't track in version control
3
-
4
- __all__ = [
5
- "__version__",
6
- "__version_tuple__",
7
- "version",
8
- "version_tuple",
9
- "__commit_id__",
10
- "commit_id",
11
- ]
12
-
13
- TYPE_CHECKING = False
14
- if TYPE_CHECKING:
15
- from typing import Tuple
16
- from typing import Union
17
-
18
- VERSION_TUPLE = Tuple[Union[int, str], ...]
19
- COMMIT_ID = Union[str, None]
20
- else:
21
- VERSION_TUPLE = object
22
- COMMIT_ID = object
23
-
24
- version: str
25
- __version__: str
26
- __version_tuple__: VERSION_TUPLE
27
- version_tuple: VERSION_TUPLE
28
- commit_id: COMMIT_ID
29
- __commit_id__: COMMIT_ID
30
-
31
- __version__ = version = '0.7.4'
32
- __version_tuple__ = version_tuple = (0, 7, 4)
33
-
34
- __commit_id__ = commit_id = None
File without changes
File without changes
File without changes
File without changes