Skip to content

Commit 95252bc

Browse files
committed
feat: Emit None for WrappedAction.Cancelation.Mode; review fixes
Change WrappedAction.Cancelation.Mode from an empty-string sentinel to None (string?) when the wrapped action defines no <Cancelation>, matching the EXPR semantics for optional data and the int? typing of Cancelation.NotifyPeriodInSeconds. None renders as the empty string in format-string interpolation, and the nullness is observable via EXPR null-coalescing. Also address review findings on the wrap-action implementation: - Fail gracefully when WrappedAction.* injection cannot resolve the wrapped action's format strings (e.g. a wrapped onRun referencing {{Task.File.*}} or onEnter referencing {{Env.File.*}} — embedded files are not materialized on the wrap path yet, a known limitation shared with the Rust runtime). The action now FAILs through the normal callback path via a new _fail_action_before_start helper and the session transitions to READY_ENDING, instead of a raw FormatStringError escaping the public API — which, for enter_environment, previously left the session stuck in RUNNING with no runner. Also covers non-integer FEATURE_BUNDLE_1 timeout/notifyPeriod resolutions. - WrappedAction.Environment now carries only openjd_env-defined variables per RFC 0008, excluding the environment's declarative variables: map seed (parity with the Rust runtime, which was already correct). - Log a warning when a wrap environment is active but run_task() was not given step_name, since {{WrappedStep.Name}} renders empty. - Hoist the Template Schemas 5.3.2 notify-period defaults (120/30) into shared constants used by the seeding and both cancel paths. - Guard _run_wrap_hook against unknown hook names so a typo cannot become a silent SUCCESS no-op. Tests assert `is None` for the undeclared cancelation case, the injection-failure and READY_ENDING behavior, the openjd_env-only Environment contents, and the hook-name guard. All 53 WRAP_ACTIONS conformance tests pass against the Python CLI. Addresses review feedback on openjd-rs PR #261 (discussion r3597572812) and openjd-specifications PR #148 (r3597560515). Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
1 parent adabe3d commit 95252bc

7 files changed

Lines changed: 363 additions & 72 deletions

File tree

src/openjd/sessions/_runner_env_script.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,12 @@
2222
TerminateCancelMethod,
2323
)
2424
from ._session_user import SessionUser
25-
from ._types import ActionModel, ActionState, EnvironmentScriptModel
25+
from ._types import (
26+
ENV_ACTION_DEFAULT_NOTIFY_PERIOD_SECONDS,
27+
ActionModel,
28+
ActionState,
29+
EnvironmentScriptModel,
30+
)
2631

2732
__all__ = ("EnvironmentScriptRunner",)
2833

@@ -195,11 +200,13 @@ def wrap_env_exit(self) -> None:
195200
substituting it for an inner environment's ``onExit``."""
196201
self._run_wrap_hook("onWrapEnvExit", default_timeout=_ENV_EXIT_DEFAULT_TIMEOUT)
197202

198-
def _run_wrap_hook(
199-
self, hook: str, *, default_timeout: Optional[timedelta] = None
200-
) -> None:
203+
def _run_wrap_hook(self, hook: str, *, default_timeout: Optional[timedelta] = None) -> None:
201204
"""Common dispatch for the three RFC 0008 wrap hooks. ``hook`` is
202205
one of ``onWrapEnvEnter``, ``onWrapTaskRun``, or ``onWrapEnvExit``."""
206+
if hook not in ("onWrapEnvEnter", "onWrapTaskRun", "onWrapEnvExit"):
207+
# Guard the getattr below: without this, a typo'd hook name
208+
# would silently become a SUCCESS no-op.
209+
raise ValueError(f"Unknown wrap hook name: {hook}")
203210
if self.state != ScriptRunnerState.READY:
204211
raise RuntimeError("This cannot be used to run a second subprocess.")
205212

@@ -247,8 +254,10 @@ def cancel(
247254
# For the type checker
248255
assert isinstance(model_cancel_method, CancelationMethodNotifyThenTerminate_2023_09)
249256
if model_cancel_method.notifyPeriodInSeconds is None:
250-
# Default grace period is 30s for a 2023-09 Environment Script's notify cancel
251-
method = NotifyCancelMethod(terminate_delay=timedelta(seconds=30))
257+
# Default grace period for a 2023-09 Environment Script's notify cancel
258+
method = NotifyCancelMethod(
259+
terminate_delay=timedelta(seconds=ENV_ACTION_DEFAULT_NOTIFY_PERIOD_SECONDS)
260+
)
252261
else:
253262
method = NotifyCancelMethod(
254263
terminate_delay=timedelta(seconds=model_cancel_method.notifyPeriodInSeconds) # type: ignore[arg-type]

src/openjd/sessions/_runner_step_script.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
TerminateCancelMethod,
2222
)
2323
from ._session_user import SessionUser
24-
from ._types import ActionState, StepScriptModel
24+
from ._types import TASK_RUN_DEFAULT_NOTIFY_PERIOD_SECONDS, ActionState, StepScriptModel
2525

2626
__all__ = ("StepScriptRunner",)
2727

@@ -139,8 +139,10 @@ def cancel(
139139
# For the type checker
140140
assert isinstance(model_cancel_method, CancelationMethodNotifyThenTerminate_2023_09)
141141
if model_cancel_method.notifyPeriodInSeconds is None:
142-
# Default grace period is 120s for a 2023-09 Step Script's notify cancel
143-
method = NotifyCancelMethod(terminate_delay=timedelta(seconds=120))
142+
# Default grace period for a 2023-09 Step Script's notify cancel
143+
method = NotifyCancelMethod(
144+
terminate_delay=timedelta(seconds=TASK_RUN_DEFAULT_NOTIFY_PERIOD_SECONDS)
145+
)
144146
else:
145147
method = NotifyCancelMethod(
146148
terminate_delay=timedelta(seconds=model_cancel_method.notifyPeriodInSeconds) # type: ignore[arg-type]

src/openjd/sessions/_session.py

Lines changed: 105 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from typing import TYPE_CHECKING, Any, Callable, Optional, Type, Union
1818

1919
from openjd.model import (
20+
FormatStringError,
2021
JobParameterValues,
2122
ParameterValue,
2223
ParameterValueType,
@@ -48,6 +49,8 @@
4849
from ._subprocess import LoggingSubprocess
4950
from ._tempdir import TempDir, custom_gettempdir
5051
from ._types import (
52+
ENV_ACTION_DEFAULT_NOTIFY_PERIOD_SECONDS,
53+
TASK_RUN_DEFAULT_NOTIFY_PERIOD_SECONDS,
5154
ActionState,
5255
EnvironmentIdentifier,
5356
EnvironmentModel,
@@ -136,6 +139,11 @@ class SimplifiedEnvironmentVariableChanges:
136139

137140
def __init__(self, initial_variables: Union[dict[str, str], "EnvironmentVariableObject"]):
138141
self._to_set: dict[str, Optional[str]]
142+
# Names of variables that were set/unset via openjd_env stdout
143+
# messages (RFC 0008: only these are surfaced through
144+
# ``WrappedAction.Environment``; the initial ``variables:`` map
145+
# seed is intentionally excluded, matching the Rust runtime).
146+
self._openjd_env_names: set[str] = set()
139147

140148
if is_windows():
141149
self._to_set = {}
@@ -147,12 +155,14 @@ def __init__(self, initial_variables: Union[dict[str, str], "EnvironmentVariable
147155
def simplify_ordered_changes(self, changes: list[EnvironmentVariableChange]) -> None:
148156
"""Apply a given list of sets and unsets to the current state in order"""
149157
for change in changes:
158+
name = change.name.upper() if is_windows() else change.name
150159
if isinstance(change, EnvironmentVariableSetChange):
151-
self._to_set[change.name.upper() if is_windows() else change.name] = change.value
160+
self._to_set[name] = change.value
152161
elif isinstance(change, EnvironmentVariableUnsetChange):
153-
self._to_set[change.name.upper() if is_windows() else change.name] = None
162+
self._to_set[name] = None
154163
else:
155164
raise ValueError("Unknown type of environment variable change.")
165+
self._openjd_env_names.add(name)
156166

157167
def apply_to_environment(self, env_vars: dict[str, Optional[str]]) -> None:
158168
"""Modify a given dictionary of environment variables to reflect the changes"""
@@ -723,7 +733,19 @@ def enter_environment(
723733
wrap_env = None
724734

725735
if wrap_env is not None:
726-
self._inject_wrapped_env_symbols(symtab, environment, on_enter_action)
736+
try:
737+
self._inject_wrapped_env_symbols(symtab, environment, on_enter_action)
738+
except (FormatStringError, ValueError) as e:
739+
# e.g. the wrapped onEnter references {{Env.File.*}} (embedded
740+
# files are not materialized on the wrap path yet). Fail the
741+
# action through the normal failure path — the environment
742+
# stays in the entered list, exactly as when enter() itself
743+
# fails, so the caller's cleanup exits it as usual.
744+
self._fail_action_before_start(
745+
f"Failed to resolve the wrapped onEnter action of "
746+
f"{environment.name} for {wrap_env.name}'s onWrapEnvEnter: {e}"
747+
)
748+
return identifier
727749
self._runner = EnvironmentScriptRunner(
728750
logger=self._logger,
729751
user=self._user,
@@ -835,7 +857,18 @@ def exit_environment(
835857
)
836858

837859
if wrap_env is not None:
838-
self._inject_wrapped_env_symbols(symtab, environment, on_exit_action)
860+
try:
861+
self._inject_wrapped_env_symbols(symtab, environment, on_exit_action)
862+
except (FormatStringError, ValueError) as e:
863+
# Mirror of the onWrapEnvEnter injection-failure handling:
864+
# fail the action through the normal failure path. The
865+
# environment was already removed from tracking above,
866+
# matching how a failed exit() behaves.
867+
self._fail_action_before_start(
868+
f"Failed to resolve the wrapped onExit action of "
869+
f"{environment.name} for {wrap_env.name}'s onWrapEnvExit: {e}"
870+
)
871+
return
839872
self._runner = EnvironmentScriptRunner(
840873
logger=self._logger,
841874
user=self._user,
@@ -918,7 +951,28 @@ def run_task(
918951
# wrap action instead of the step script's onRun (RFC 0008).
919952
wrap_env = self._find_wrap_environment(hook="onWrapTaskRun")
920953
if wrap_env is not None:
921-
self._inject_wrapped_task_symbols(symtab, step_script, step_name or "")
954+
if step_name is None:
955+
# RFC 0008: without a step name, {{WrappedStep.Name}}
956+
# renders as the empty string in the wrap script. Callers
957+
# predating the step_name kwarg won't pass it; make the
958+
# gap visible rather than silently rendering empty.
959+
self._logger.warning(
960+
"A wrap environment is active but run_task() was not given a "
961+
"step_name; WrappedStep.Name will render as an empty string."
962+
)
963+
try:
964+
self._inject_wrapped_task_symbols(symtab, step_script, step_name or "")
965+
except (FormatStringError, ValueError) as e:
966+
# e.g. the wrapped onRun references {{Task.File.*}} (embedded
967+
# files are not materialized on the wrap path yet), or a
968+
# FEATURE_BUNDLE_1 timeout/notifyPeriod format string did not
969+
# resolve to an integer. Fail the action through the normal
970+
# failure path rather than raising out of the public API.
971+
self._fail_action_before_start(
972+
f"Failed to resolve the wrapped Task action for {wrap_env.name}'s "
973+
f"onWrapTaskRun: {e}"
974+
)
975+
return
922976

923977
self._runner = EnvironmentScriptRunner(
924978
logger=self._logger,
@@ -1227,21 +1281,27 @@ def _environment_defines_any_wrap_hook(self, env: EnvironmentModel) -> bool:
12271281
if env.script is None:
12281282
return False
12291283
return any(
1230-
hasattr(env.script.actions, name)
1231-
and getattr(env.script.actions, name) is not None
1284+
hasattr(env.script.actions, name) and getattr(env.script.actions, name) is not None
12321285
for name in self._WRAP_HOOK_NAMES
12331286
)
12341287

12351288
def _collect_session_env_list(self) -> list[str]:
12361289
"""Collect all ``openjd_env``-defined variables across the active
12371290
environment stack as ``["KEY=value", ...]`` for
1238-
``WrappedAction.Environment``."""
1291+
``WrappedAction.Environment``.
1292+
1293+
RFC 0008 defines this variable as carrying only ``openjd_env``
1294+
definitions from the current session; an environment's declarative
1295+
``variables:`` map (and host-inherited variables) are intentionally
1296+
excluded, matching the Rust runtime."""
12391297
env_list: list[str] = []
12401298
for env_id in self._environments_entered:
12411299
if env_id in self._created_env_vars:
12421300
changes = self._created_env_vars[env_id]
1301+
# Iterate _to_set (insertion-ordered) rather than the name
1302+
# set so the list order is deterministic.
12431303
for key, value in changes._to_set.items():
1244-
if value is not None:
1304+
if key in changes._openjd_env_names and value is not None:
12451305
env_list.append(f"{key}={value}")
12461306
return env_list
12471307

@@ -1262,11 +1322,12 @@ def _inject_wrapped_cancelation_symbols(
12621322
action's ``<Cancelation>`` (RFC 0008 follow-up,
12631323
openjd-specifications#148).
12641324
1265-
``Mode`` is ``"TERMINATE"``, ``"NOTIFY_THEN_TERMINATE"``, or the
1266-
empty string when the wrapped action defines no ``<Cancelation>`` —
1267-
the empty case is deliberately distinct from an explicit
1268-
``TERMINATE`` so wrap scripts can tell "author declared TERMINATE"
1269-
apart from "author declared nothing".
1325+
``Mode`` is typed ``string?``: ``"TERMINATE"``,
1326+
``"NOTIFY_THEN_TERMINATE"``, or ``None`` (rendering as
1327+
``null``/empty in format strings) when the wrapped action defines
1328+
no ``<Cancelation>`` — the null case is deliberately distinct from
1329+
an explicit ``TERMINATE`` so wrap scripts can tell "author declared
1330+
TERMINATE" apart from "author declared nothing".
12701331
12711332
``NotifyPeriodInSeconds`` is typed ``int?``: the effective grace
12721333
period when the mode is ``NOTIFY_THEN_TERMINATE``, applying the
@@ -1278,10 +1339,10 @@ def _inject_wrapped_cancelation_symbols(
12781339
applicable" is not conflated with a zero-length notify period.
12791340
"""
12801341
cancelation = getattr(action, "cancelation", None)
1281-
mode: str
1342+
mode: Optional[str]
12821343
notify_period: Optional[int]
12831344
if cancelation is None:
1284-
mode = ""
1345+
mode = None
12851346
notify_period = None
12861347
elif cancelation.mode == CancelationMode_2023_09.TERMINATE:
12871348
mode = CancelationMode_2023_09.TERMINATE.value
@@ -1290,7 +1351,11 @@ def _inject_wrapped_cancelation_symbols(
12901351
mode = CancelationMode_2023_09.NOTIFY_THEN_TERMINATE.value
12911352
period = getattr(cancelation, "notifyPeriodInSeconds", None)
12921353
if period is None:
1293-
notify_period = 120 if is_task_run else 30
1354+
notify_period = (
1355+
TASK_RUN_DEFAULT_NOTIFY_PERIOD_SECONDS
1356+
if is_task_run
1357+
else ENV_ACTION_DEFAULT_NOTIFY_PERIOD_SECONDS
1358+
)
12941359
elif isinstance(period, int):
12951360
notify_period = period
12961361
else:
@@ -1308,17 +1373,11 @@ def _inject_wrapped_env_symbols(
13081373
"""Populate ``WrappedAction.*`` and ``WrappedEnv.Name`` for
13091374
``onWrapEnvEnter`` / ``onWrapEnvExit`` scripts (RFC 0008)."""
13101375
command = inner_action.command.resolve(symtab=symtab)
1311-
args = (
1312-
[a.resolve(symtab=symtab) for a in inner_action.args]
1313-
if inner_action.args
1314-
else []
1315-
)
1376+
args = [a.resolve(symtab=symtab) for a in inner_action.args] if inner_action.args else []
13161377
symtab["WrappedAction.Command"] = command
13171378
symtab["WrappedAction.Args"] = args
13181379
symtab["WrappedAction.Environment"] = self._collect_session_env_list()
1319-
symtab["WrappedAction.Timeout"] = self._resolve_action_timeout(
1320-
inner_action, symtab
1321-
)
1380+
symtab["WrappedAction.Timeout"] = self._resolve_action_timeout(inner_action, symtab)
13221381
self._inject_wrapped_cancelation_symbols(symtab, inner_action, is_task_run=False)
13231382
symtab["WrappedEnv.Name"] = environment.name
13241383

@@ -1339,9 +1398,7 @@ def _inject_wrapped_task_symbols(
13391398

13401399
symtab["WrappedAction.Command"] = on_run.command.resolve(symtab=symtab)
13411400
symtab["WrappedAction.Args"] = (
1342-
[arg.resolve(symtab=symtab) for arg in on_run.args]
1343-
if on_run.args
1344-
else []
1401+
[arg.resolve(symtab=symtab) for arg in on_run.args] if on_run.args else []
13451402
)
13461403
symtab["WrappedAction.Environment"] = self._collect_session_env_list()
13471404
symtab["WrappedAction.Timeout"] = self._resolve_action_timeout(on_run, symtab)
@@ -1526,6 +1583,26 @@ def _action_log_filter_callback(
15261583
assert action_status is not None
15271584
self._callback(self._session_id, action_status)
15281585

1586+
def _fail_action_before_start(self, message: str) -> None:
1587+
"""Mark the pending action as FAILED before any runner/subprocess
1588+
exists (RFC 0008: e.g. when resolving the wrapped action's format
1589+
strings for ``WrappedAction.*`` injection fails).
1590+
1591+
Mirrors the failure branch of :meth:`_action_callback` — the
1592+
session transitions to READY_ENDING so the caller can exit the
1593+
entered environments — but does not require ``self._runner``.
1594+
"""
1595+
self._logger.error(message)
1596+
self._action_fail_message = message
1597+
self._action_exit_code = None
1598+
self._action_state = ActionState.FAILED
1599+
self._state = SessionState.READY_ENDING
1600+
if self._callback:
1601+
action_status = self.action_status
1602+
# for the type checker
1603+
assert action_status is not None
1604+
self._callback(self._session_id, action_status)
1605+
15291606
def _action_callback(self, state: ActionState) -> None:
15301607
"""This callback is invoked:
15311608
1. When the Action process is successfully started, by the same thread that is running the

src/openjd/sessions/_types.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,14 @@
2020
EnvironmentModel = Environment_2023_09
2121
EnvironmentScriptModel = EnvironmentScript_2023_09
2222

23+
# Default notifyPeriodInSeconds for a NOTIFY_THEN_TERMINATE cancelation
24+
# when the action omits the field (2023-09 Template Schemas 5.3.2).
25+
TASK_RUN_DEFAULT_NOTIFY_PERIOD_SECONDS = 120
26+
"""Default notify period for a Step Script's onRun action."""
27+
ENV_ACTION_DEFAULT_NOTIFY_PERIOD_SECONDS = 30
28+
"""Default notify period for any other action (e.g. an Environment's
29+
onEnter/onExit)."""
30+
2331

2432
class ActionState(str, Enum):
2533
RUNNING = "running"

0 commit comments

Comments
 (0)