feat: RFC 0008 environment wrap actions#333
Conversation
| for key, value in changes._to_set.items(): | ||
| if value is not None: | ||
| env_list.append(f"{key}={value}") | ||
| return env_list |
There was a problem hiding this comment.
_collect_session_env_list builds WrappedAction.Environment by iterating each environment’s _to_set independently and emitting KEY=value for every non-None entry. This does not reproduce the cumulative env semantics that apply_to_environment (and thus the real subprocess env in action_env_vars) uses:
- Overrides produce duplicates. If env A sets
FOO=1and env B later setsFOO=2, the returned list is["FOO=1", "FOO=2"]— both entries, rather than just the effectiveFOO=2. - Later unsets are dropped silently. If env A sets
FOO=1and env B unsetsFOO(_to_set[FOO] = None), theNoneis skipped butFOO=1from A is still emitted, so the wrap action seesFOO=1even though it has been unset.
Consider flattening the same way the runtime does — e.g. build a single ordered dict by applying each environment’s changes in _environments_entered order (reusing apply_to_environment/_evaluate_current_session_env_vars logic) and then emit KEY=value for the surviving non-None entries — so WrappedAction.Environment matches the environment the wrapped action actually runs in.
There was a problem hiding this comment.
Fixed in 6b98605 — _collect_session_env_list now flattens the per-environment changes cumulatively in environment-entry order into a single effective map, so a later openjd_env set yields one entry (the effective value) and a later unset removes the name entirely, matching the real subprocess environment and the Rust runtime's single cumulative env_vars map. Regression tests reproduce both symptoms against the old code (['FOO=1', 'FOO=2'] and the stale-after-unset value) and pin the new behavior.
There was a problem hiding this comment.
Fixed in 6b98605 — _collect_session_env_list now flattens the per-environment changes cumulatively in environment-entry order into a single effective map, so a later openjd_env set yields one entry (the effective value) and a later unset removes the name entirely — matching the real subprocess environment and the Rust runtime's single cumulative env_vars map. Regression tests reproduce both symptoms against the old code (['FOO=1', 'FOO=2'] for the override, and the stale value surviving an unset) and pin the new behavior.
|
|
||
| if wrap_env is not None: | ||
| try: | ||
| self._inject_wrapped_env_symbols(symtab, environment, on_exit_action) |
There was a problem hiding this comment.
On the onWrapEnvExit path, WrappedAction.Environment omits the exiting environment’s own openjd_env variables, but the real subprocess environment includes them — an inconsistency.
The exiting environment is removed from tracking (del self._environments[identifier]; self._environments_entered.pop()) at lines 831–832, before _inject_wrapped_env_symbols calls _collect_session_env_list() here. Since _collect_session_env_list() iterates self._environments_entered, it no longer sees the exiting environment, so its openjd_env-defined vars are excluded from WrappedAction.Environment.
However, action_env_vars (the actual OS env handed to the runner) was computed at line 828 before the pop, so it does include the exiting environment’s vars. Net effect: the wrapped onExit runs in a subprocess whose real environment contains the exiting env’s openjd_env vars, yet the WrappedAction.Environment list the wrap script inspects does not list them. A wrap script that reconstructs the child env from WrappedAction.Environment (rather than inheriting the OS env) would run onExit without those variables.
The onWrapTaskRun and onWrapEnvEnter paths do not have this problem (nothing is popped before collection). Consider collecting the env list before the pop, or otherwise including the exiting environment on the exit path so WrappedAction.Environment matches the environment the wrapped onExit actually runs in.
There was a problem hiding this comment.
Fixed in 6b98605 — exit_environment now captures the openjd_env list before the exiting environment is removed from tracking (right after action_env_vars is computed, so the two stay consistent) and threads it into _inject_wrapped_env_symbols via a new session_env_list parameter. The wrapped onExit's WrappedAction.Environment now includes the exiting environment's own openjd_env variables, matching the real subprocess environment; the enter and task paths are unchanged. A regression test drives the full enter→exit cycle and fails against the old code with ENVLIST=<[]>.
d757cd7 to
06e30ee
Compare
|
|
||
| symtab["WrappedAction.Command"] = on_run.command.resolve(symtab=symtab) | ||
| symtab["WrappedAction.Args"] = ( | ||
| [arg.resolve(symtab=symtab) for arg in on_run.args] if on_run.args else [] |
There was a problem hiding this comment.
WrappedAction.Args may not faithfully represent what the wrapped action would actually receive.
The real execution path in _run_action (_runner_base.py) now resolves each arg with arg.resolve_value(...) and applies RFC 0005 §1.3.2 typed semantics: a whole-field expression that resolves to null is skipped, and a list-typed result is flattened into one argument per element. But here (and in _inject_wrapped_env_symbols, line 1575) the wrapped action's args are resolved with plain arg.resolve(symtab=symtab), which yields a string form for every arg.
Consequences for a wrapped onRun/onEnter whose args use EXPR:
- A null-resolving whole-field arg is dropped during real execution but appears in
WrappedAction.Argsas an empty string. - A list-valued arg is expanded to N arguments during real execution but appears as a single joined/string-formatted element in
WrappedAction.Args.
A wrap script that reconstructs the command from WrappedAction.Args (e.g. repr_sh(WrappedAction.Args)) would then run a different argument vector than the unwrapped action would have. Consider routing this through the same resolve_value logic used by _run_action so the two stay in sync.
| # the symbol table before resolving the action, so the action's | ||
| # `{{ }}` expressions (and any wrap-action seeding below) can reference | ||
| # them. | ||
| self._apply_let_bindings(symtab, getattr(step_script, "let", None), step_script.revision) |
There was a problem hiding this comment.
On the wrap paths, the wrong script's let bindings are applied.
In run_task the wrap branch runs wrap_env.script (the wrapping environment's onWrapTaskRun), but the only _apply_let_bindings call here binds the step script's let (line 1006). The wrapping environment's own let bindings are never evaluated, so onWrapTaskRun cannot reference them. The same pattern holds in enter_environment (line 698 applies the inner env's let, then runs wrap_env.script's onWrapEnvEnter) and exit_environment (line 875).
Conversely, applying the wrapped script's let into the symtab that then evaluates the wrapper action can leak/collide bindings: if the wrap script also defines a binding of the same name, the resolution order/visibility is not what an author would expect.
If the intent is that a wrap hook resolves against its own let bindings (plus the WrappedAction.* seeds), then _apply_let_bindings should be called with wrap_env.script's let on these branches — and it's worth deciding explicitly whether the wrapped script's let should be visible at all during wrapper evaluation.
| for rule in self._path_mapping_rules or []: | ||
| rules.append( | ||
| ExprPathMappingRule( | ||
| source_path_format=getattr(ExprPathFormat, rule.source_path_format.name), |
There was a problem hiding this comment.
_build_expr_host_rules only catches ImportError, but getattr(ExprPathFormat, rule.source_path_format.name) can raise AttributeError.
For a URI path-mapping rule, rule.source_path_format.name is "URI". URI is a newly-added member of this package's PathFormat (see _path_mapping.py), but the engine's openjd.expr.PathFormat may not define a URI member on every openjd-model version in the supported range (>=0.10,<0.11). When it doesn't, getattr(...) raises AttributeError, which is not caught here and propagates straight out of Session.__init__ (this method is called from the constructor). A session that merely has a URI rule would then fail to construct at all — even for non-EXPR jobs that never use apply_path_mapping().
The method already degrades gracefully when the engine is entirely absent (the ImportError branch returns None); a partially-featured engine should degrade the same way. Consider catching AttributeError (or using getattr(ExprPathFormat, name, None) and skipping/falling back when the member is missing) so a URI rule cannot break session construction.
| """Amount of time after a SIGTERM to wait to do the SIGKILL""" | ||
|
|
||
|
|
||
| def resolve_effective_cancelation( |
There was a problem hiding this comment.
resolve_effective_cancelation() correctly rejects bad values (mode that isn't
TERMINATE/NOTIFY_THEN_TERMINATE, period > 600). But nothing calls it when an
action starts:
_run_action()checks command, args, and timeout — but never the cancelation.- The
cancel()methods do call it, but they catch the error and just log a
warning, then fall back to Terminate.
This is contradicting to https://github.com/OpenJobDescription/openjd-model-for-python/pull/313/changes#diff-83e7bc6a17553c9802c434993041f50f8bc44aeb7ea05f46e5e850c72525c59cR406
Since the helper is already written and unit-tested, could we add a call to it
in _run_action() and fail the action if it raises?
…ement the WRAP_ACTIONS extension (RFC 0008) in the v0 session, with EXPR (RFC 0007) runtime parity with openjd-rs: - Wrap-hook dispatch: an environment's onWrapEnvEnter / onWrapTaskRun / onWrapEnvExit runs in place of an inner environment's onEnter/onExit or a step's onRun, with WrappedAction.Command/Args/Environment/ Timeout/Cancelation.* and WrappedEnv.Name/WrappedStep.Name injected. At most one wrap-defining environment may be active (enforced at enter time). - Two strictly separated scopes (openjd-rs OpenJobDescription#277 parity): the wrapped action's values resolve against the INNER entity's own scope (its script-level let bindings and embedded files, materialized in runner order: paths, lets, contents); the hook script resolves against the wrap environment's own scope (its lets, evaluated by the runner) plus the WrappedAction.* overlay. Same-named lets on the two sides each resolve to their own value. - WrappedAction.Environment carries every session-defined variable: openjd_env definitions and entered environments' declarative variables: maps; host-inherited variables are excluded. - EXPR runtime: runner-evaluated script-level let bindings ordered around embedded-file materialization (paths before lets, contents after), typed symbol tables, enter_environment(extra_let_bindings=...) so a step's environments see the step-level lets. - Cancelation: WrappedAction.Cancelation.Mode/NotifyPeriodInSeconds resolved through the enforcement path, with Template Schemas 5.3.2 defaults (120s task onRun / 30s otherwise). Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
Review fixes on top of the RFC 0008 wrap-actions commit: - enter_environment/exit_environment no longer raise a raw ExpressionError out of the public API when an extra `let` binding fails to evaluate: the action fails through _fail_action_before_start with the callback, leaving the environment entered-but-failed exactly like a failed onEnter subprocess. - enter_environment(step_name=...) seeds Step.Name (RFC 0007 §7.3.1) before the extra bindings evaluate, remembered per environment and re-seeded at exit — openjd-rs parity (the Rust runtime threads the per-step resolved symtab into both enter and exit). - let-binding RHS parsing memoized and single-sourced via openjd.model.evaluate_let_bindings (was re-parsed through the Rust engine per env-enter/exit/task). - Wrap environment embedded-file paths allocated once per environment and reused across wrap-hook invocations (stable Env.File.*, no O(task-count) temp-file growth); contents still re-resolved per task. - One resolve_optional_int_field(ge=, le=) replaces three disagreeing optional-int resolvers — this also adds the previously missing non-positive check to Session._resolve_action_timeout (openjd-rs parity). - Complexity/duplication reductions: _fail_action, shared runner cancel() via _cancel_with_effective_cancelation, _make_env_script_runner, _try_inject_wrapped_symbols, WRAP_HOOK_ACTION_NAMES single-sourced, SimplifiedEnvironmentVariableChanges.effective_items() (no more private poking from _collect_session_env_list), whole-field detection via FormatString.whole_field_expression(). - pyproject: note that the openjd-model pin floor must be the first release containing model PR OpenJobDescription#318's surface. Tests: 647 passed (12 new regression tests: failure paths, Step.Name enter+exit, parse memoization, int-field bounds, wrap-file reuse); pre-existing environment failures unchanged. mypy/black/ruff clean. Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
|
|
||
| symtab["WrappedAction.Command"] = on_run.command.resolve(symtab=inner_symtab) | ||
| symtab["WrappedAction.Args"] = ( | ||
| [arg.resolve(symtab=inner_symtab) for arg in on_run.args] if on_run.args else [] |
There was a problem hiding this comment.
WrappedAction.Args is resolved here with arg.resolve(...) (plain per-arg string resolution), but the actual enforcement path in ScriptRunnerBase._run_action (_runner_base.py:656) now resolves args with arg.resolve_value(...) and applies RFC 0005 §1.3.2 semantics: a whole-field expression that resolves to null is skipped, and a list result is flattened inline (one argument per element).
Because the two paths diverge, WrappedAction.Args will not match the arguments the runtime would actually pass in the unwrapped case:
- A null-resolving whole-field arg (e.g.
"{{Task.Param.Foo}}"where the param is optional/null) is dropped by_run_action, but here it resolves to the empty string and becomes a spurious""element in the list. A wrap script forwardingargs: "{{ repr_sh(WrappedAction.Args) }}"would then pass an extra empty argument. - A list-typed whole-field arg is flattened into multiple arguments by
_run_action, but herearg.resolve()yields a single stringified value, so it becomes one element instead of N.
To keep the wrapped value faithful to what the runtime enforces, WrappedAction.Args should be built with the same resolve_value() + null-skip/list-flatten logic used in _run_action (ideally sharing a single helper). The same applies to _inject_wrapped_env_symbols (line 1778).
Two Rust-parity fixes in the v0 session runtime: 1. WrappedAction.Args now uses RFC 0005 1.3.2 typed argument semantics. The enforcement path's typed arg loop (null skip, list flattening, display coercion) is extracted into a shared module-level helper, resolve_action_arg_values, in _runner_base.py; both _inject_wrapped_task_symbols and _inject_wrapped_env_symbols now use it, so a wrap hook sees exactly the argv the wrapped action would have run with unwrapped -- mirroring openjd-rs, whose seed_wrapped_action_symbols resolves through the same resolve_action_args as the runner. The helper also gains openjd-rs's plain-string-resolution fallback when typed resolution fails. 2. An empty string is no longer conflated with null. resolve_optional_int_field and resolve_effective_cancelation's deferred-mode branch now resolve typed (resolve_value) and treat only a typed null result as "field omitted" / "no cancelation declared". A genuine empty string now reaches the "must be a positive integer, got ''" / "must resolve to ... got ''" errors, matching openjd-rs's resolve_action_timeout, resolve_notify_period_seconds, and resolve_effective_cancelation, which only special-case ExprValue::Null. The whole_field_expression() pre-check is removed: resolve_value only yields a typed null for whole-field expressions, so null semantics remain whole-field-only by construction. Test updates: the deferred-cancelation unit-test helper now parses its format strings with the EXPR extension (deferred forwarding is an RFC 0008 construct and WRAP_ACTIONS requires EXPR), since the previous legacy parse pinned the non-Rust "" == null behavior. Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
| return False, path | ||
| remainder = inp_path[len(src_path) :] | ||
| if remainder and not remainder.startswith("/"): | ||
| return False, path |
There was a problem hiding this comment.
When a URI rule's source_path ends in / (a common S3 prefix form, e.g. s3://bucket/prefix/), child paths fail to map.
Trace with source = "s3://bucket/prefix/" (so src_path = "/prefix/") and input path = "s3://bucket/prefix/a" (inp_path = "/prefix/a"):
inp_path.startswith(src_path)→ Trueremainder = inp_path[len(src_path):]→"a"remainder and not remainder.startswith("/")→ True → returns(False, path)
So the child is not mapped at all. The component-boundary check assumes src_path never ends in /; when it does, the remainder for a real child begins with the child name rather than /, and the guard rejects it. Exact-match still works, but children silently pass through unmapped.
Consider normalizing a trailing / off the source path portion before comparison, so both s3://bucket/prefix and s3://bucket/prefix/ behave identically for children.
TL;DR
This PR teaches the Python v0 session runtime the RFC 0008 "wrap actions" extension: an environment that defines onWrapEnvEnter / onWrapTaskRun / onWrapEnvExit intercepts inner environments' enter/exit actions and tasks' run actions, running its own hook instead — with the wrapped action's fully-resolved command, args, environment variables, timeout, and cancelation config injected as WrappedAction.* template variables. To do that faithfully it also lands the EXPR (RFC 0007) runtime machinery the feature rides on: script-level let bindings evaluated in the same order as openjd-rs, typed symbol tables, and cancel-time resolution of format-string cancelation modes. The openjd-rs crate is the reference throughout, and the Python code mirrors it closely — including the two-scope separation that keeps a wrapper's names from ever leaking into the wrapped action's resolved values. What deserves the most scrutiny: the three wrap-interception branches in _session.py, and the two small Rust-parity gaps found in this pass.
What was the problem/requirement? (What/Why)
RFC 0008 (
WRAP_ACTIONS) lets a job author write a "wrapper" environment whose hook scripts (onWrapEnvEnter,onWrapTaskRun,onWrapEnvExit) run in place of an inner environment'sonEnter/onExitor a step'sonRun— the classic use case is running every task inside a container. The v0 Python session runtime had no support for wrap hooks, and needed EXPR (RFC 0007) runtime parity with openjd-rs for the pieces the hooks depend on (typed symbol tables, script-levelletbindings, embedded-file ordering).What was the solution? (How)
One squashed commit implementing:
WrappedAction.Command/Args/Environment/Timeout/Cancelation.*andWrappedEnv.Name/WrappedStep.Nameinjected into the hook's symbol table. At most one wrap-defining environment may be active (enforced at enter time).letbindings and embedded files, materialized in runner order (paths → lets → contents) — while the hook script resolves against the wrap environment's own scope plus theWrappedAction.*overlay. Aletname bound by both sides resolves to each side's own value (regression-tested). Wrapped actions referencingTask.File.*/Env.File.*resolve to real on-disk paths.WrappedAction.Environmentcarries every session-defined variable:openjd_envdefinitions and entered environments' declarativevariables:maps (host-inherited variables excluded), matching openjd-rs chore(deps): update ruff requirement from ==0.13.* to ==0.14.* #277 and the cross-platform fixtures in openjd-specifications.letbindings ordered around embedded-file materialization, typed symbol tables, andenter_environment(extra_let_bindings=...)(optional, defaultNone— fully backwards compatible) so a step's environments see the step-level lets.WrappedAction.Cancelation.Mode/NotifyPeriodInSecondsresolved through the same enforcement path the runtime uses, with the Template Schemas 5.3.2 defaults (120 s for a task'sonRun, 30 s otherwise).What is the impact of this change?
Wrapper environments behave per RFC 0008 in the v0 Python session runtime, with scope isolation guarantees identical to openjd-rs. The public API is backwards compatible: the only signature change is the new optional
extra_let_bindingskeyword onenter_environment, defaulting toNone.How was this change tested?
hatch run teston macOS; the only intermittent failures are two pre-existing xdist timing flakes intest_run_action_default_timeout/test_cancel_notifythat pass in isolation and also fail identically on mainline).mypy(hatch run typing) andblack/ruff(hatch run style): clean.letname on both sides; innerletinvisible to the hook), inner embedded-file resolution throughWrappedAction.Command,variables:-map inclusion inWrappedAction.Environment, and cancelation-mode/notify-period injection.python-rsbranch installed): WRAP_ACTIONS 68/72 — the 4 remaining failures are the wrap-cancelation fixtures that require openjd-model-for-python chore(release): 0.10.9 #313 to land in the model first.Depends on: openjd-model-for-python
python-rsbranch (unreleased model APIs). Merge after the model change.Review fixes (second commit)
fix: Address review findings — let-binding crash paths, Step.Name, perf, from a multi-aspect review (crashes / performance / maintainability) with a post-fix clean sweep:enter_environment/exit_environmentno longer raise a rawExpressionErrorout of the public API when an extraletbinding fails — the action fails through the normal callback path, leaving the environment entered-but-failed like a failed onEnter subprocess.enter_environment(step_name=...)seedsStep.Name(RFC 0007 §7.3.1) before the extra bindings evaluate, remembered per environment and re-seeded at exit — the v0 counterpart of the per-step resolved symtab the Rust runtime threads into enter/exit.openjd.model.evaluate_let_bindings; wrap-environment embedded-file paths allocated once per environment and reused across wrap-hook invocations (stableEnv.File.*, no O(task-count) temp-file growth) while contents are still re-resolved per task.resolve_optional_int_field(ge=, le=)replaces three disagreeing optional-int resolvers — this also adds the previously missing non-positive check toSession._resolve_action_timeout(openjd-rs parity)._fail_action, shared runnercancel(),_make_env_script_runner,_try_inject_wrapped_symbols,WRAP_HOOK_ACTION_NAMESsingle-sourced,effective_items()accessor, whole-field detection via the model's parsedwhole_field_expression().Tests after fixes: 647 passed (12 new regression tests); pre-existing environment failures unchanged; mypy/black/ruff clean. Note for release: the
openjd-modelpin floor must be the first model release containing openjd-model-for-python#318's surface (comment added above the pin).