Skip to content

Commit 6b98605

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). - WrappedAction.Environment is flattened cumulatively in environment-entry order, so a later openjd_env set yields one effective entry (not a duplicate) and a later unset removes the name — matching the real subprocess environment and Rust's single cumulative env_vars map. - On the onWrapEnvExit path, the openjd_env list is captured before the exiting environment is removed from tracking, so the wrapped environment's own openjd_env variables appear in WrappedAction.Environment just as they do in the real subprocess environment. - 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 (including override/unset flattening and the exit-path capture), 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 6b98605

7 files changed

Lines changed: 466 additions & 76 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: 139 additions & 32 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,
@@ -805,6 +827,14 @@ def exit_environment(
805827
# Must be run _before_ we pop _environments_entered
806828
action_env_vars = self._evaluate_current_session_env_vars(os_env_vars)
807829

830+
# RFC 0008: capture the openjd_env list for WrappedAction.Environment
831+
# _before_ the exiting environment is removed from tracking, so the
832+
# list includes that environment's own openjd_env variables — the
833+
# real subprocess environment (action_env_vars, computed above)
834+
# includes them, and the wrapped onExit runs with them in the
835+
# unwrapped case too.
836+
wrapped_session_env_list = self._collect_session_env_list()
837+
808838
# Remove the environment from our tracking since we're now exiting it.
809839
del self._environments[identifier]
810840
self._environments_entered.pop()
@@ -835,7 +865,23 @@ def exit_environment(
835865
)
836866

837867
if wrap_env is not None:
838-
self._inject_wrapped_env_symbols(symtab, environment, on_exit_action)
868+
try:
869+
self._inject_wrapped_env_symbols(
870+
symtab,
871+
environment,
872+
on_exit_action,
873+
session_env_list=wrapped_session_env_list,
874+
)
875+
except (FormatStringError, ValueError) as e:
876+
# Mirror of the onWrapEnvEnter injection-failure handling:
877+
# fail the action through the normal failure path. The
878+
# environment was already removed from tracking above,
879+
# matching how a failed exit() behaves.
880+
self._fail_action_before_start(
881+
f"Failed to resolve the wrapped onExit action of "
882+
f"{environment.name} for {wrap_env.name}'s onWrapEnvExit: {e}"
883+
)
884+
return
839885
self._runner = EnvironmentScriptRunner(
840886
logger=self._logger,
841887
user=self._user,
@@ -918,7 +964,28 @@ def run_task(
918964
# wrap action instead of the step script's onRun (RFC 0008).
919965
wrap_env = self._find_wrap_environment(hook="onWrapTaskRun")
920966
if wrap_env is not None:
921-
self._inject_wrapped_task_symbols(symtab, step_script, step_name or "")
967+
if step_name is None:
968+
# RFC 0008: without a step name, {{WrappedStep.Name}}
969+
# renders as the empty string in the wrap script. Callers
970+
# predating the step_name kwarg won't pass it; make the
971+
# gap visible rather than silently rendering empty.
972+
self._logger.warning(
973+
"A wrap environment is active but run_task() was not given a "
974+
"step_name; WrappedStep.Name will render as an empty string."
975+
)
976+
try:
977+
self._inject_wrapped_task_symbols(symtab, step_script, step_name or "")
978+
except (FormatStringError, ValueError) as e:
979+
# e.g. the wrapped onRun references {{Task.File.*}} (embedded
980+
# files are not materialized on the wrap path yet), or a
981+
# FEATURE_BUNDLE_1 timeout/notifyPeriod format string did not
982+
# resolve to an integer. Fail the action through the normal
983+
# failure path rather than raising out of the public API.
984+
self._fail_action_before_start(
985+
f"Failed to resolve the wrapped Task action for {wrap_env.name}'s "
986+
f"onWrapTaskRun: {e}"
987+
)
988+
return
922989

923990
self._runner = EnvironmentScriptRunner(
924991
logger=self._logger,
@@ -1227,23 +1294,37 @@ def _environment_defines_any_wrap_hook(self, env: EnvironmentModel) -> bool:
12271294
if env.script is None:
12281295
return False
12291296
return any(
1230-
hasattr(env.script.actions, name)
1231-
and getattr(env.script.actions, name) is not None
1297+
hasattr(env.script.actions, name) and getattr(env.script.actions, name) is not None
12321298
for name in self._WRAP_HOOK_NAMES
12331299
)
12341300

12351301
def _collect_session_env_list(self) -> list[str]:
12361302
"""Collect all ``openjd_env``-defined variables across the active
12371303
environment stack as ``["KEY=value", ...]`` for
1238-
``WrappedAction.Environment``."""
1239-
env_list: list[str] = []
1304+
``WrappedAction.Environment``.
1305+
1306+
RFC 0008 defines this variable as carrying only ``openjd_env``
1307+
definitions from the current session; an environment's declarative
1308+
``variables:`` map (and host-inherited variables) are intentionally
1309+
excluded, matching the Rust runtime.
1310+
1311+
The per-environment changes are flattened cumulatively, in
1312+
environment-entry order, so the list reflects the *effective*
1313+
state exactly like the real subprocess environment does: a later
1314+
``openjd_env`` set overrides an earlier value for the same name
1315+
(one entry, not two), and a later unset removes the name entirely.
1316+
This mirrors the Rust runtime's single cumulative ``env_vars``
1317+
map."""
1318+
effective: dict[str, Optional[str]] = {}
12401319
for env_id in self._environments_entered:
12411320
if env_id in self._created_env_vars:
12421321
changes = self._created_env_vars[env_id]
1322+
# Iterate _to_set (insertion-ordered) rather than the name
1323+
# set so the list order is deterministic.
12431324
for key, value in changes._to_set.items():
1244-
if value is not None:
1245-
env_list.append(f"{key}={value}")
1246-
return env_list
1325+
if key in changes._openjd_env_names:
1326+
effective[key] = value
1327+
return [f"{key}={value}" for key, value in effective.items() if value is not None]
12471328

12481329
def _resolve_action_timeout(self, action: Any, symtab: SymbolTable) -> int:
12491330
"""Return the wrapped action's timeout as an int (seconds), or 0
@@ -1262,11 +1343,12 @@ def _inject_wrapped_cancelation_symbols(
12621343
action's ``<Cancelation>`` (RFC 0008 follow-up,
12631344
openjd-specifications#148).
12641345
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".
1346+
``Mode`` is typed ``string?``: ``"TERMINATE"``,
1347+
``"NOTIFY_THEN_TERMINATE"``, or ``None`` (rendering as
1348+
``null``/empty in format strings) when the wrapped action defines
1349+
no ``<Cancelation>`` — the null case is deliberately distinct from
1350+
an explicit ``TERMINATE`` so wrap scripts can tell "author declared
1351+
TERMINATE" apart from "author declared nothing".
12701352
12711353
``NotifyPeriodInSeconds`` is typed ``int?``: the effective grace
12721354
period when the mode is ``NOTIFY_THEN_TERMINATE``, applying the
@@ -1278,10 +1360,10 @@ def _inject_wrapped_cancelation_symbols(
12781360
applicable" is not conflated with a zero-length notify period.
12791361
"""
12801362
cancelation = getattr(action, "cancelation", None)
1281-
mode: str
1363+
mode: Optional[str]
12821364
notify_period: Optional[int]
12831365
if cancelation is None:
1284-
mode = ""
1366+
mode = None
12851367
notify_period = None
12861368
elif cancelation.mode == CancelationMode_2023_09.TERMINATE:
12871369
mode = CancelationMode_2023_09.TERMINATE.value
@@ -1290,7 +1372,11 @@ def _inject_wrapped_cancelation_symbols(
12901372
mode = CancelationMode_2023_09.NOTIFY_THEN_TERMINATE.value
12911373
period = getattr(cancelation, "notifyPeriodInSeconds", None)
12921374
if period is None:
1293-
notify_period = 120 if is_task_run else 30
1375+
notify_period = (
1376+
TASK_RUN_DEFAULT_NOTIFY_PERIOD_SECONDS
1377+
if is_task_run
1378+
else ENV_ACTION_DEFAULT_NOTIFY_PERIOD_SECONDS
1379+
)
12941380
elif isinstance(period, int):
12951381
notify_period = period
12961382
else:
@@ -1304,21 +1390,24 @@ def _inject_wrapped_env_symbols(
13041390
symtab: SymbolTable,
13051391
environment: EnvironmentModel,
13061392
inner_action: Any,
1393+
session_env_list: Optional[list[str]] = None,
13071394
) -> None:
13081395
"""Populate ``WrappedAction.*`` and ``WrappedEnv.Name`` for
1309-
``onWrapEnvEnter`` / ``onWrapEnvExit`` scripts (RFC 0008)."""
1396+
``onWrapEnvEnter`` / ``onWrapEnvExit`` scripts (RFC 0008).
1397+
1398+
``session_env_list`` overrides the collected openjd_env list when
1399+
the caller must capture it at a different point in time — the
1400+
``onWrapEnvExit`` path collects it before the exiting environment
1401+
is removed from tracking, so the wrapped environment's own
1402+
``openjd_env`` variables are included."""
13101403
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-
)
1404+
args = [a.resolve(symtab=symtab) for a in inner_action.args] if inner_action.args else []
13161405
symtab["WrappedAction.Command"] = command
13171406
symtab["WrappedAction.Args"] = args
1318-
symtab["WrappedAction.Environment"] = self._collect_session_env_list()
1319-
symtab["WrappedAction.Timeout"] = self._resolve_action_timeout(
1320-
inner_action, symtab
1407+
symtab["WrappedAction.Environment"] = (
1408+
session_env_list if session_env_list is not None else self._collect_session_env_list()
13211409
)
1410+
symtab["WrappedAction.Timeout"] = self._resolve_action_timeout(inner_action, symtab)
13221411
self._inject_wrapped_cancelation_symbols(symtab, inner_action, is_task_run=False)
13231412
symtab["WrappedEnv.Name"] = environment.name
13241413

@@ -1339,9 +1428,7 @@ def _inject_wrapped_task_symbols(
13391428

13401429
symtab["WrappedAction.Command"] = on_run.command.resolve(symtab=symtab)
13411430
symtab["WrappedAction.Args"] = (
1342-
[arg.resolve(symtab=symtab) for arg in on_run.args]
1343-
if on_run.args
1344-
else []
1431+
[arg.resolve(symtab=symtab) for arg in on_run.args] if on_run.args else []
13451432
)
13461433
symtab["WrappedAction.Environment"] = self._collect_session_env_list()
13471434
symtab["WrappedAction.Timeout"] = self._resolve_action_timeout(on_run, symtab)
@@ -1526,6 +1613,26 @@ def _action_log_filter_callback(
15261613
assert action_status is not None
15271614
self._callback(self._session_id, action_status)
15281615

1616+
def _fail_action_before_start(self, message: str) -> None:
1617+
"""Mark the pending action as FAILED before any runner/subprocess
1618+
exists (RFC 0008: e.g. when resolving the wrapped action's format
1619+
strings for ``WrappedAction.*`` injection fails).
1620+
1621+
Mirrors the failure branch of :meth:`_action_callback` — the
1622+
session transitions to READY_ENDING so the caller can exit the
1623+
entered environments — but does not require ``self._runner``.
1624+
"""
1625+
self._logger.error(message)
1626+
self._action_fail_message = message
1627+
self._action_exit_code = None
1628+
self._action_state = ActionState.FAILED
1629+
self._state = SessionState.READY_ENDING
1630+
if self._callback:
1631+
action_status = self.action_status
1632+
# for the type checker
1633+
assert action_status is not None
1634+
self._callback(self._session_id, action_status)
1635+
15291636
def _action_callback(self, state: ActionState) -> None:
15301637
"""This callback is invoked:
15311638
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)