feat: RFC 0007/0008 — step-level let bindings, wrap-hook step names, template extensions - #230
feat: RFC 0007/0008 — step-level let bindings, wrap-hook step names, template extensions#230leongdl wants to merge 3 commits into
Conversation
… 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>
| 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), |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 []) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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>
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
letbindings and wrap-hook environments run correctly throughopenjd run.What was the solution? (How)
One squashed commit on top of mainline:
letbindings (RFC 0007) so environment variables and actions can reference them. Backwards compatible: theextra_let_bindingskeyword is only forwarded to openjd-sessions when a step actually definesletbindings, 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").step_nametoSession.run_taskso RFC 0008 wrap hooks resolveWrappedStep.Name.Job.Nameinto the symbol table.test_local_session.pymock-call assertions for the environment-enter flow.What is the impact of this change?
openjd runsupports 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?
hatch run test, macOS) with the RFC 0008 model/sessions branches installed (one intermittentTaskTimeouttiming flake under parallel load passes in isolation).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:RuntimeError, expressionValueErrors) now surface as an error-status result instead of a raw traceback.variablesexpression) keeps the env tracked so cleanup exits it in LIFO order instead of masking the original error.step_nameis threaded intoSession.enter_environmentfor step-environment enters soStep.Name(RFC 0007 §7.3.1) resolves in step-levelletbindings and step-env actions (openjd-rsrun/mod.rsthreads 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.)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 guardThis commit is why the PR is now unblocking rather than optional.
openjd-sessions 0.10.11 is released, and on that version
run_task()raiseswhen a wrap environment is active and no
step_nameis given. The released CLIdoes not pass
step_name, and released openjd-model 0.11.1 does carry theWRAP_ACTIONS extension — so with the previous
>= 0.10.7floor, a released stackcould 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:
RunTaskActionpassesstep_name=unconditionally, and that keyword does notexist before 0.10.11, so below the new floor every task run would raise
TypeError. 0.10.11 is the first release withstep_nameon bothrun_taskandenter_environment, so the floor moves there and the feature detection goes away.The guard was suppressing the tests that prove RFC 0007
Step.Nameworks,which is the part worth reviewing:
test_localsession_step_env_enter_receives_step_namebranched on the detectionflag 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 thatresolves
Step.Nameinside a step environment, wasskipif-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 (
EnterEnvironmentActionandRunTaskActioneach stopping forwardingstep_name), 0 survivors.extra_let_bindingskeeps its truthiness check andstep_namekeeps itsis not Nonecheck, but neither is feature detection now — they are "only forwardwhat carries something". Job and external environment enters have no owning step,
so
Step.Namemust stay undefined for them rather than be seeded withNone,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_dispatchonly, so openjd-cli needs adeliberate release trigger.