Skip to content

feat: RFC 0007/0008 — step-level let bindings, wrap-hook step names, template extensions - #230

Open
leongdl wants to merge 3 commits into
OpenJobDescription:mainlinefrom
leongdl:rfc008
Open

feat: RFC 0007/0008 — step-level let bindings, wrap-hook step names, template extensions#230
leongdl wants to merge 3 commits into
OpenJobDescription:mainlinefrom
leongdl:rfc008

Conversation

@leongdl

@leongdl leongdl commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

What was the problem/requirement? (What/Why)

The CLI needed the RFC 0007 (EXPR) / RFC 0008 (WRAP_ACTIONS) integration pieces so job templates using step-level let bindings and wrap-hook environments run correctly through openjd run.

What was the solution? (How)

One squashed commit on top of mainline:

  • Enter a step's environments with the step-level EXPR let bindings (RFC 0007) so environment variables and actions can reference them. Backwards compatible: the extra_let_bindings keyword is only forwarded to openjd-sessions when a step actually defines let bindings, so the CLI keeps working against older sessions releases for every template that doesn't use step-level lets (the default is simply "no extra bindings").
  • Pass step_name to Session.run_task so RFC 0008 wrap hooks resolve WrappedStep.Name.
  • Enable only the extensions each template declares, and seed Job.Name into the symbol table.
  • Update test_local_session.py mock-call assertions for the environment-enter flow.

What is the impact of this change?

openjd run supports RFC 0007/0008 templates end-to-end when paired with the RFC 0008 sessions branch, and continues to work with older sessions releases for templates that don't use the new features.

How was this change tested?

  • Full unit suite: 289 passed, 2 skipped, 0 failed (hatch run test, macOS) with the RFC 0008 model/sessions branches installed (one intermittent TaskTimeout timing flake under parallel load passes in isolation).
  • OpenJD conformance suite (2023-09) against this CLI: 1154/1158, including all WRAP_ACTIONS scope/variables fixtures; the 4 remaining failures are wrap-cancelation fixtures blocked on openjd-model-for-python #313.

Dependency status: openjd-sessions-for-python #333 is merged and released as 0.10.11, which this PR now requires. openjd-model 0.11.1 (released) carries the WRAP_ACTIONS extension.


Review fixes (second commit)

fix: Address review findings — clean errors, Step.Name threading, from a multi-aspect review with a post-fix clean sweep:

  • Clean error results: exceptions raised out of session actions (the RFC 0008 "at most one wrap environment" RuntimeError, expression ValueErrors) now surface as an error-status result instead of a raw traceback.
  • Rollback correctness: the enter-failure handler 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.
  • Step.Name parity with the Rust CLI: step_name is threaded 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 (openjd-rs run/mod.rs threads the step's resolved symtab). (That commit capability-gated this; the third commit below removes the gate, because the dependency floor now guarantees the keyword.)
  • Redundant kwarg-forwarding branch collapsed. (The version-skew detection this added is removed by the third commit below — its removal condition is now met.)

Tests after fixes: 294 passed under plain hatch and against the editable fixed model/sessions trees (new: Step.Name end-to-end template test, post-registration rollback, clean-error-result). ruff/black/mypy clean.


Dependency floor and guard removal (third commit)

fix: Require openjd-sessions >= 0.10.11 and drop the version-skew guard

This commit is why the PR is now unblocking rather than optional.
openjd-sessions 0.10.11 is released, and on that version run_task() raises
when a wrap environment is active and no step_name is given. The released CLI
does not pass step_name, and released openjd-model 0.11.1 does carry the
WRAP_ACTIONS extension — so with the previous >= 0.10.7 floor, a released stack
could author a wrap template and every wrapped task run would fail. This PR is the
fix for that.

The floor also could not satisfy the calls this package already made:
RunTaskAction passes step_name= unconditionally, and that keyword does not
exist before 0.10.11, so below the new floor every task run would raise
TypeError. 0.10.11 is the first release with step_name on both run_task and
enter_environment, so the floor moves there and the feature detection goes away.

The guard was suppressing the tests that prove RFC 0007 Step.Name works,
which is the part worth reviewing:

  • test_localsession_step_env_enter_receives_step_name branched on the detection
    flag and, when false, asserted the keyword was absent — passing while proving
    the opposite of its name.
  • test_do_run_step_name_in_step_environment, the only end-to-end test that
    resolves Step.Name inside a step environment, was skipif-ed away entirely.

Against a dev environment whose openjd-sessions predated the keyword, both
reported success while the feature was untested. Both are now unconditional, and
both fail if either call site stops forwarding the name — verified by mutation:
2 mutants (EnterEnvironmentAction and RunTaskAction each stopping forwarding
step_name), 0 survivors.

extra_let_bindings keeps its truthiness check and step_name keeps its
is not None check, but neither is feature detection now — they are "only forward
what carries something". Job and external environment enters have no owning step,
so Step.Name must stay undefined for them rather than be seeded with None,
which the existing assertions pin.

Verified on this commit: 295 passed, 2 skipped locally (both skips are
Windows-shell tests); ruff, black and mypy clean; all 17 CI checks green across
ubuntu/macOS/Windows x Python 3.9-3.12, with CI resolving openjd-sessions 0.10.11
so the two un-gated tests really ran.

After merge: the release is workflow_dispatch only, so openjd-cli needs a
deliberate release trigger.

… names, template-declared extensions - Enter a step's environments with the step-level EXPR let bindings (RFC 0007) so environment variables and actions can reference them. The extra_let_bindings keyword is only forwarded to openjd-sessions when a step actually defines lets, so the CLI keeps working against older sessions releases for every template that doesn't use step-level lets. - Pass step_name to Session.run_task so RFC 0008 wrap hooks resolve WrappedStep.Name. - Enable only the extensions each template declares, and seed Job.Name into the symbol table. - Update test_local_session.py mock-call assertions for the environment-enter flow.

Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
@leongdl
leongdl requested a review from a team as a code owner July 22, 2026 21:27
session_id=self.session_id,
job_parameter_values=job_parameter_values,
# RFC 0007 §7.3.1 (EXPR): seeds the Job.Name template variable.
job_name=str(job.name),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent backwards-compatibility handling for new openjd-sessions keywords.

This PR carefully guards extra_let_bindings — the comment at enter_environment (in _actions.py) states that older openjd-sessions releases do not accept that keyword, so it is only forwarded when step-level let bindings exist. But job_name here (and step_name in _actions.py:55, passed to run_task) is passed unconditionally, and pyproject.toml still pins openjd-sessions >= 0.10.7, < 0.11 (unchanged by this PR).

If the RFC-0007/0008 keywords (job_name, step_name) are not present in the minimum supported sessions version — which is exactly the premise behind guarding extra_let_bindings — then Session(...) and run_task(...) will raise TypeError: unexpected keyword argument for any user on that version, breaking every openjd run invocation regardless of whether the template uses these extensions.

Either raise the openjd-sessions lower bound to the first release that accepts job_name/step_name, or guard these the same way extra_let_bindings is guarded. Whichever is correct, the three new keywords should be handled consistently.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, and it was correct when you wrote it — resolved since, by raising the
floor rather than adding guards.

Verified against the released sessions tags, checking the three keywords in
_session.py:

sessions Session(job_name=) run_task(step_name=) enter_environment(extra_let_bindings=)
0.10.7 absent absent absent
0.10.10 absent absent absent
0.10.11 present present present

So at the old >= 0.10.7 floor all three would have raised
TypeError: unexpected keyword argument — not just the two you flagged. The
extra_let_bindings guard was not protecting anything the others needed
protecting from; it was just the only one that had a guard.

4e9a384 on this branch raises the floor to openjd-sessions >= 0.10.11, < 0.11,
which is the first release carrying all three, and drops the inspect.signature
version-skew probing entirely. Consistency achieved by removing the one guard
rather than adding two.

The if self._extra_let_bindings: conditional in _actions.py is still there but
is now semantic rather than compatibility: don't forward an empty binding list.

One asymmetry does remain deliberately, and it is the neighbouring
getattr(step, "let", None) you mention in your other comment. That one is still
load-bearing, because the model floor is untouched at >= 0.9, < 0.12 and
StepTemplate.let only arrives in openjd-model 0.11.0:

openjd-model StepTemplate.let
0.9.0 / 0.10.0 / 0.10.1 absent
0.11.0 present

So the sessions floor is now tight and needs no probing, while the model floor is
still wide and does. Same-looking code, different reason — worth the comment it
already has.

# Extension behaviors activate only when declared by the templates being
# run (the job template and any external environment templates) — the
# CLI's --extensions list is what the CLI *accepts*, not what is enabled.
declared_extensions: list[str] = list(the_job.extensions or [])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Direct .extensions attribute access may raise AttributeError on older openjd-model.

the_job.extensions (line 554) and env_template.extensions (line 556) are new attribute accesses. pyproject.toml pins openjd-model >= 0.9, < 0.12 (unchanged by this PR). If the extensions attribute was introduced in a later model version than 0.9, these accesses raise AttributeError on the lower end of the supported range, breaking openjd run entirely.

Note the asymmetry with _session_manager.py:369, which defensively uses getattr(step, "let", None) for a similarly new model attribute. Consider using the same defensive pattern here (e.g. getattr(the_job, "extensions", None) or []), or raise the openjd-model floor to the first version that guarantees extensions on Job and EnvironmentTemplate.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked this one directly and the premise does not hold: extensions is present
on both classes at the lower bound of the pinned range, so the bare access is
safe and no getattr is needed.

Job and EnvironmentTemplate are declared in
openjd/model/v2023_09/_model.py, and both carry extensions as far back as
openjd-model 0.9.0 — checked per release tag:

openjd-model Job.extensions EnvironmentTemplate.extensions
0.9.0 present present
0.10.1 present present
0.11.0 present present
current mainline present present

So the_job.extensions and env_template.extensions cannot raise
AttributeError anywhere in openjd-model >= 0.9, < 0.12, and the floor does not
need raising for this.

On the asymmetry with getattr(step, "let", None) at _session_manager.py: that
one is not equivalent, which is why it is written defensively.
StepTemplate.let only appears in openjd-model 0.11.0, well above the >= 0.9
floor, so it genuinely can be missing:

openjd-model StepTemplate.let
0.9.0 / 0.10.0 / 0.10.1 absent
0.11.0 present

Two similar-looking accesses with different availability floors, so the differing
treatment is intentional rather than an oversight.

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>
# popping it here would skip its onExit and desynchronize us
# from the session's LIFO exit ordering check, masking the
# original error with "Must exit Environment X first".
if env_id not in self._openjd_session.environments_entered:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unguarded self._openjd_session.environments_entered in the new error path may itself raise AttributeError on older openjd-sessions.

This block is otherwise carefully defensive about version skew — step_name acceptance is probed with inspect.signature (_actions.py), and step.let is read via getattr(step, "let", None). But environments_entered is read bare on the Session. pyproject.toml still pins openjd-sessions >= 0.10.7, < 0.11 (unchanged by this PR). If this public property was introduced after 0.10.7, the access raises AttributeError here.

The consequence is worse than usual because this is inside the except (RuntimeError, ValueError) handler: an AttributeError here is not caught, so it escapes and masks the original enter failure with an unrelated traceback (and skips setting self.failed / raising LocalSessionFailed).

Please confirm environments_entered is present in the minimum pinned openjd-sessions (0.10.7). If it is guaranteed, this is fine; if not, guard it the same way as the neighboring session APIs (e.g. getattr(self._openjd_session, "environments_entered", ())) and raise the version floor.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed as you asked, and environments_entered is safe: it predates the floor
by some margin, so it was never at risk even under the old >= 0.10.7 pin.

Session.environments_entered is present in openjd/sessions/_session.py at
every relevant released tag:

openjd-sessions environments_entered
0.10.7 (the floor at the time of your comment) present
0.10.10 present
0.10.11 (the floor now) present

So the bare read cannot raise AttributeError, and the masking scenario you
describe — an AttributeError escaping the except (RuntimeError, ValueError)
handler, hiding the original enter failure and skipping LocalSessionFailed — is
not reachable. Your reasoning about the consequence was right; only the
availability premise was off.

Separately, 4e9a384 on this branch has since raised the floor to
openjd-sessions >= 0.10.11, < 0.11 and removed the inspect.signature probing
that the neighbouring code used, so the version-skew story here is simpler than
it was when you commented. Details in the reply on the job_name thread.

The declared floor could not satisfy the calls this package makes. `RunTaskAction`
passes `step_name=` to `Session.run_task` unconditionally, but that keyword does
not exist before openjd-sessions 0.10.11 -- so against the old `>= 0.10.7` floor
every task run would raise `TypeError: run_task() got an unexpected keyword
argument 'step_name'`.

0.10.11 is released and is the first version with `step_name` on **both**
`run_task` and `enter_environment`, so the floor moves there and the
feature-detection guard goes away. That guard was doing real harm rather than
providing real compatibility:

- `_ENTER_ENVIRONMENT_ACCEPTS_STEP_NAME` gated only `enter_environment`, while
  `run_task` was already unguarded. So the two call sites disagreed about which
  sessions versions this package supported, and the stricter of the two was the
  ungated one.
- **It silenced the tests that prove RFC 0007 Step.Name works.**
  `test_localsession_step_env_enter_receives_step_name` branched on the flag and,
  when it was false, asserted the keyword was *absent* -- passing while proving
  the opposite of its name. Worse,
  `test_do_run_step_name_in_step_environment`, the only end-to-end test that
  actually resolves `Step.Name` inside a step environment, was `skipif`-ed away
  entirely. In a dev environment with a sessions build predating the keyword, both
  reported success while the feature was untested.

Both are now unconditional, and both fail if either call site stops forwarding the
name: 2 mutants, 0 survivors (`EnterEnvironmentAction` and `RunTaskAction` each
stopping forwarding `step_name`).

`extra_let_bindings` keeps its `if self._extra_let_bindings:` check, and
`step_name` keeps its `is not None` check, but neither is feature-detection now --
they are "only forward what carries something". Job and external environment
enters have no owning step, so `Step.Name` must stay undefined for them rather
than be seeded with None, which the existing assertions pin.

Release ordering note: openjd-sessions 0.10.11 raises when a wrap environment is
active and no `step_name` is given, so the released CLI cannot run wrapped tasks
until this lands. That makes this the unblocking change rather than a cleanup.

Verified: 295 passed, 2 skipped (both Windows-shell tests); ruff, black, and mypy
clean.

Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant