Skip to content

Commit daed283

Browse files
committed
fix: Address review findings — clean errors, Step.Name threading
Review fixes on top of the RFC 0007/0008 CLI commit: - Exceptions raised out of session actions (the RFC 0008 "at most one wrap environment" RuntimeError, expression ValueErrors) now surface as a clean error-status result instead of a raw traceback: guarded in run_environment_enters (routed through LocalSessionFailed) with a belt-and-suspenders catch in _run_local_session. - The enter-failure rollback only drops the environment from the CLI's entered list when the session did not register it — a post- registration raise (e.g. a failing `variables` expression) keeps the env tracked so cleanup exits it in LIFO order instead of masking the original error with "Must exit Environment X first". - Thread step_name into Session.enter_environment for step-environment enters so Step.Name (RFC 0007 §7.3.1) resolves in step-level `let` bindings and step-env actions — Rust CLI parity (openjd-rs run/mod.rs threads the step's resolved symtab). Capability-gated on the installed openjd-sessions accepting the kwarg. - run_step's redundant pass-kwarg-only-when-truthy branch collapsed (the compat guard lives in EnterEnvironmentAction.run); version-skew getattr documented with its removal condition. Tests: 294 passed under plain hatch and against the editable fixed model/sessions trees (new: Step.Name end-to-end template, step_name propagation, post-registration rollback, clean-error-result). ruff/black/mypy clean. Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
1 parent 5b56d66 commit daed283

6 files changed

Lines changed: 289 additions & 28 deletions

File tree

src/openjd/cli/_run/_local_session/_actions.py

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,22 @@
11
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
22

3+
import inspect
34
from enum import Enum
4-
from typing import Optional
5+
from typing import Any, Optional
56

67
from openjd.model import Step, TaskParameterSet
78
from openjd.model.v2023_09 import Environment
89
from openjd.sessions import Session
910

11+
# Version-skew guard: the step_name keyword was added to
12+
# Session.enter_environment alongside RFC 0007 Step.Name support. Detect it
13+
# once so this CLI keeps working against older openjd-sessions releases that
14+
# don't accept the keyword (every step-environment enter carries a step name,
15+
# so a value-presence check alone isn't enough here).
16+
_ENTER_ENVIRONMENT_ACCEPTS_STEP_NAME: bool = (
17+
"step_name" in inspect.signature(Session.enter_environment).parameters
18+
)
19+
1020

1121
class EnvironmentType(str, Enum):
1222
"""
@@ -64,39 +74,49 @@ class EnterEnvironmentAction(SessionAction):
6474
_environment: Environment
6575
_id: str
6676
_extra_let_bindings: Optional[list[str]]
77+
_step_name: Optional[str]
6778

6879
def __init__(
6980
self,
7081
session: Session,
7182
environment: Environment,
7283
env_id: str,
7384
extra_let_bindings: Optional[list[str]] = None,
85+
step_name: Optional[str] = None,
7486
):
7587
super(EnterEnvironmentAction, self).__init__(session)
7688
self._environment = environment
7789
self._id = env_id
7890
# RFC 0007: a step's environments are entered with the step-level
7991
# `let` bindings so their variables/actions can reference them.
8092
self._extra_let_bindings = extra_let_bindings
93+
# RFC 0007 §7.3.1 (EXPR): the owning step's name seeds Step.Name for
94+
# a step environment's `let` bindings, variables, and actions. Only
95+
# step-environment enters carry a step name; job/external enters
96+
# leave it None.
97+
self._step_name = step_name
8198

8299
def run(self):
83100
# Backwards compatibility: only forward `extra_let_bindings` when the
84101
# step actually defines `let` bindings (RFC 0007). Older
85102
# openjd-sessions releases don't accept the keyword, so omitting it
86103
# by default keeps this CLI working with any sessions version for
87104
# every template that doesn't use step-level lets — the reasonable
88-
# default is simply "no extra bindings".
105+
# default is simply "no extra bindings". `step_name` follows the same
106+
# pattern, but since every step-environment enter has a step name, it
107+
# is additionally gated on the installed openjd-sessions accepting
108+
# the keyword; without it, Step.Name simply stays undefined (the
109+
# pre-RFC 0007 behavior).
110+
optional_kwargs: dict[str, Any] = {}
89111
if self._extra_let_bindings:
90-
self._session.enter_environment(
91-
environment=self._environment,
92-
identifier=self._id,
93-
extra_let_bindings=self._extra_let_bindings,
94-
)
95-
else:
96-
self._session.enter_environment(
97-
environment=self._environment,
98-
identifier=self._id,
99-
)
112+
optional_kwargs["extra_let_bindings"] = self._extra_let_bindings
113+
if self._step_name is not None and _ENTER_ENVIRONMENT_ACCEPTS_STEP_NAME:
114+
optional_kwargs["step_name"] = self._step_name
115+
self._session.enter_environment(
116+
environment=self._environment,
117+
identifier=self._id,
118+
**optional_kwargs,
119+
)
100120

101121
def __str__(self):
102122
return f"Enter Environment '{self._environment.name}'"

src/openjd/cli/_run/_local_session/_session_manager.py

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ def run_environment_enters(
205205
type: EnvironmentType,
206206
*,
207207
extra_let_bindings: Optional[list[str]] = None,
208+
step_name: Optional[str] = None,
208209
):
209210
"""Enter one or more environments in the session."""
210211
if environments is None:
@@ -225,9 +226,37 @@ def run_environment_enters(
225226
# RFC 0007: a step's environments see the step-level `let`
226227
# bindings.
227228
extra_let_bindings=extra_let_bindings,
229+
# RFC 0007 §7.3.1 (EXPR): a step's environments see Step.Name.
230+
# Only step-environment enters carry a step name.
231+
step_name=step_name,
228232
)
229233
self._environments_entered.append((type, env_id))
230-
self._current_action.run()
234+
try:
235+
self._current_action.run()
236+
except (RuntimeError, ValueError) as exc:
237+
# Session.enter_environment raises (rather than reporting
238+
# through the action-status callback) when it rejects the
239+
# environment up front — e.g. the RFC 0008 "at most one wrap
240+
# environment" RuntimeError, or a ValueError from the extra
241+
# `let` bindings — but it can also raise *after* registering
242+
# the environment (e.g. an environment `variables` expression
243+
# that fails to evaluate). Only when the session did NOT
244+
# register the environment may we drop it from our entered
245+
# list (cleanup must not try to exit it). If the session did
246+
# register it, it must stay in our list so cleanup exits it —
247+
# popping it here would skip its onExit and desynchronize us
248+
# from the session's LIFO exit ordering check, masking the
249+
# original error with "Must exit Environment X first".
250+
if env_id not in self._openjd_session.environments_entered:
251+
self._environments_entered.pop()
252+
LOG.info(
253+
msg=f"Open Job Description CLI: ERROR entering environment '{env.name}': {exc}",
254+
extra={"session_id": self.session_id},
255+
)
256+
self.failed = True
257+
self._failed_action = self._current_action
258+
self._current_action = None
259+
raise LocalSessionFailed(self._failed_action) from exc
231260
self._action_ended.wait()
232261
if self.failed:
233262
self._failed_action = self._current_action
@@ -363,18 +392,20 @@ def run_step(
363392

364393
# Enter all the step environments. When the step defines step-level
365394
# `let` bindings (RFC 0007), its environments are entered with them so
366-
# their variables and actions can reference them. The keyword is only
367-
# passed when bindings exist, keeping the call (and the sessions API
368-
# it reaches) identical to the pre-RFC 0007 behavior by default.
395+
# their variables and actions can reference them.
396+
# getattr guard: requires an openjd-model with Step.let on the
397+
# instantiated Job (openjd-model PR #318+); collapse to plain
398+
# `step.let` once the version pin floor guarantees it.
369399
step_let_bindings = getattr(step, "let", None)
370-
if step_let_bindings:
371-
self.run_environment_enters(
372-
step.stepEnvironments,
373-
EnvironmentType.STEP,
374-
extra_let_bindings=step_let_bindings,
375-
)
376-
else:
377-
self.run_environment_enters(step.stepEnvironments, EnvironmentType.STEP)
400+
self.run_environment_enters(
401+
step.stepEnvironments,
402+
EnvironmentType.STEP,
403+
extra_let_bindings=step_let_bindings or None,
404+
# RFC 0007 §7.3.1 (EXPR): Step.Name is available to a step's
405+
# environments (openjd-rs threads the step's resolved symbol
406+
# table into enter_environment; this is the CLI counterpart).
407+
step_name=step.name,
408+
)
378409

379410
try:
380411
# Run the tasks

src/openjd/cli/_run/_run_command.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,7 @@ def _run_local_session(
338338
Creates a Session object and listens for log messages to synchronously end the session.
339339
"""
340340

341+
error_message = "Session ended with errors; see Task logs for details"
341342
try:
342343
start_seconds = time.perf_counter()
343344

@@ -367,6 +368,14 @@ def _run_local_session(
367368
except LocalSessionFailed:
368369
duration = time.perf_counter() - start_seconds
369370
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}"
370379

371380
preserved_message: str = ""
372381
if retain_working_dir and session is not None:
@@ -377,7 +386,7 @@ def _run_local_session(
377386
if session is None or session.failed:
378387
return OpenJDRunResult(
379388
status="error",
380-
message="Session ended with errors; see Task logs for details" + preserved_message,
389+
message=error_message + preserved_message,
381390
job_name=job.name,
382391
step_name=step_name,
383392
duration=duration,
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# RFC 0007 §7.3.1 (EXPR): Step.Name is available to a step's environments.
2+
# The step-level `let` binding references Step.Name, and the step's
3+
# environment is entered with the binding so its onEnter action can echo it.
4+
specificationVersion: jobtemplate-2023-09
5+
extensions:
6+
- EXPR
7+
name: StepNameEnvJob
8+
steps:
9+
- name: EchoStepName
10+
let:
11+
- bound_name = Step.Name
12+
stepEnvironments:
13+
- name: NameEcho
14+
script:
15+
actions:
16+
onEnter:
17+
command: python
18+
args:
19+
- -c
20+
- print('EnvSaw={{ bound_name }}')
21+
script:
22+
actions:
23+
onRun:
24+
command: python
25+
args:
26+
- -c
27+
- print('TaskRan')

test/openjd/cli/test_local_session.py

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from . import SampleSteps, SESSION_PARAMETERS
88
from openjd.model import StepParameterSpaceIterator
99
from openjd.sessions import Session, SessionState
10+
from openjd.cli._run._local_session._actions import _ENTER_ENVIRONMENT_ACCEPTS_STEP_NAME
1011
from openjd.cli._run._local_session._session_manager import (
1112
LocalSession,
1213
EnvironmentType,
@@ -168,7 +169,13 @@ def test_localsession_run_success(
168169
assert patched_run_environment_enters.call_args_list == [
169170
call(session, None, EnvironmentType.EXTERNAL),
170171
call(session, sample_job.jobEnvironments, EnvironmentType.JOB),
171-
call(session, sample_job.steps[step_index].stepEnvironments, EnvironmentType.STEP),
172+
call(
173+
session,
174+
sample_job.steps[step_index].stepEnvironments,
175+
EnvironmentType.STEP,
176+
extra_let_bindings=None,
177+
step_name=sample_job.steps[step_index].name,
178+
),
172179
]
173180
# It should have run one step
174181
assert patched_run_step.call_args_list == [
@@ -193,6 +200,115 @@ def test_localsession_run_success(
193200
)
194201

195202

203+
@pytest.mark.usefixtures("sample_job_and_dirs")
204+
def test_localsession_step_env_enter_receives_step_name(
205+
sample_job_and_dirs: tuple,
206+
patched_actions,
207+
):
208+
"""
209+
RFC 0007 §7.3.1 (EXPR): a step-environment enter passes the owning step's
210+
name to Session.enter_environment (when the installed openjd-sessions
211+
accepts the keyword), while job/external environment enters never do.
212+
"""
213+
sample_job, sample_job_parameters, template_dir, current_working_dir = sample_job_and_dirs
214+
patched_enter = patched_actions[0]
215+
216+
with LocalSession(
217+
job=sample_job, job_parameter_values=sample_job_parameters, session_id="step-name"
218+
) as session:
219+
session.run_step(sample_job.steps[SampleSteps.NormalStep])
220+
221+
assert not session.failed
222+
223+
step_env_calls = [
224+
c
225+
for c in patched_enter.call_args_list
226+
if c.kwargs["identifier"].startswith(f"{EnvironmentType.STEP.name} - ")
227+
]
228+
other_env_calls = [
229+
c
230+
for c in patched_enter.call_args_list
231+
if not c.kwargs["identifier"].startswith(f"{EnvironmentType.STEP.name} - ")
232+
]
233+
234+
assert step_env_calls
235+
for enter_call in step_env_calls:
236+
if _ENTER_ENVIRONMENT_ACCEPTS_STEP_NAME:
237+
assert enter_call.kwargs["step_name"] == sample_job.steps[SampleSteps.NormalStep].name
238+
else:
239+
# Older openjd-sessions releases don't accept the keyword; the
240+
# version-skew guard must omit it.
241+
assert "step_name" not in enter_call.kwargs
242+
243+
# Job/external environment enters never carry a step name.
244+
assert other_env_calls
245+
for enter_call in other_env_calls:
246+
assert "step_name" not in enter_call.kwargs
247+
248+
249+
@pytest.mark.usefixtures("sample_job_and_dirs", "capsys")
250+
def test_localsession_enter_environment_post_registration_raise(
251+
sample_job_and_dirs: tuple, capsys: pytest.CaptureFixture, patched_actions
252+
):
253+
"""
254+
A raise out of Session.enter_environment *after* the session registered
255+
the environment (e.g. an environment `variables` expression that fails to
256+
evaluate) must leave the environment in the CLI's entered list so cleanup
257+
exits it. Popping it would desynchronize CLI and session state: the
258+
environment's onExit would be skipped and cleanup would trip the session's
259+
LIFO exit check ("Must exit Environment X first"), masking the original
260+
error.
261+
"""
262+
sample_job, sample_job_parameters, template_dir, current_working_dir = sample_job_and_dirs
263+
patched_enter = patched_actions[0]
264+
265+
# The autouse fixture set the mock's side_effect to the real (pre-patch)
266+
# Session.enter_environment; capture it before redirecting the mock.
267+
real_enter = patched_enter.side_effect
268+
error_text = "Failed to evaluate the environment's variables"
269+
270+
def register_then_raise(session, *, environment, identifier=None, **kwargs):
271+
if identifier is not None and identifier.startswith(f"{EnvironmentType.STEP.name} - "):
272+
# Mirror the state Session.enter_environment leaves behind when an
273+
# environment `variables` expression fails to evaluate: the
274+
# environment is registered in the session's entered list, but the
275+
# enter raises before any action runs.
276+
session._environments[identifier] = environment
277+
session._environments_entered.append(identifier)
278+
raise ValueError(error_text)
279+
return real_enter(session, environment=environment, identifier=identifier, **kwargs)
280+
281+
step_env_id = f"{EnvironmentType.STEP.name} - env1"
282+
job_env_id = f"{EnvironmentType.JOB.name} - rootEnv"
283+
284+
# Redirect the autouse fixture's Session.enter_environment mock from the
285+
# real method to the registering-then-raising simulation.
286+
patched_enter.side_effect = register_then_raise
287+
with LocalSession(
288+
job=sample_job, job_parameter_values=sample_job_parameters, session_id="post-reg"
289+
) as session:
290+
with pytest.raises(LocalSessionFailed):
291+
session.run_step(sample_job.steps[SampleSteps.NormalStep])
292+
293+
# The session registered the environment, so the CLI must keep it
294+
# in its own entered list for cleanup to exit.
295+
assert step_env_id in session._openjd_session.environments_entered
296+
assert (EnvironmentType.STEP, step_env_id) in session._environments_entered
297+
298+
# Cleanup exited the registered step environment and then the job
299+
# environment, in LIFO order, rather than skipping the step environment.
300+
exited_ids = [
301+
exit_call.kwargs["identifier"]
302+
for exit_call in session._openjd_session.exit_environment.call_args_list # type: ignore
303+
]
304+
assert exited_ids == [step_env_id, job_env_id]
305+
306+
assert session.failed
307+
output = capsys.readouterr().out
308+
assert error_text in output
309+
assert "Must exit Environment" not in output
310+
311+
196312
@pytest.mark.usefixtures("sample_job_and_dirs", "capsys")
197313
def test_localsession_run_failed(sample_job_and_dirs: tuple, capsys: pytest.CaptureFixture):
198314
"""
@@ -221,6 +337,8 @@ def test_localsession_run_failed(sample_job_and_dirs: tuple, capsys: pytest.Capt
221337
session,
222338
sample_job.steps[SampleSteps.BadCommand].stepEnvironments,
223339
EnvironmentType.STEP,
340+
extra_let_bindings=None,
341+
step_name=sample_job.steps[SampleSteps.BadCommand].name,
224342
),
225343
]
226344
session._openjd_session.exit_environment.assert_called_once() # type: ignore

0 commit comments

Comments
 (0)