Skip to content

fix: Re-evaluate a step's template-scope let bindings in template scope - #362

Open
leongdl wants to merge 11 commits into
OpenJobDescription:mainlinefrom
leongdl:fix/template-scope-let-path-format
Open

fix: Re-evaluate a step's template-scope let bindings in template scope#362
leongdl wants to merge 11 commits into
OpenJobDescription:mainlinefrom
leongdl:fix/template-scope-let-path-format

Conversation

@leongdl

@leongdl leongdl commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

What changed

A session now evaluates exactly one let scope: the script's own, in the host's path format. It no longer re-evaluates a step's template-scope let.

A step's template-scope let (RFC 0007 §3.6) is resolved once at job creation with PathFormat.POSIX, so a create-time value cannot depend on the host that created the job. openjd-model used to also merge those bindings into script.let, and the runners re-evaluated the merged list at session time in the host's format, re-rendering its PATH values. On Windows, startswith(path("/foo/bar"), "/foo") flips from true to false between the two evaluations.

The re-evaluation was not a harmless second opinion. The seeded create-time value and the re-evaluated one land in the same symbol table, and the re-evaluation writes last, so it overwrote the correct value.

The fix is to delete the re-evaluation and read the resolved table that already arrives through Step.resolved_symtab. That removed 268 lines from _runner_base.py relative to this branch's high-water mark: the template-scope/session-scope split, the format-neutral symbol filter that let a POSIX evaluation run against a host-format symbol table, the output-side re-tag, the boundary-marker plumbing, and the all-or-nothing fallback. None of it is needed once nothing re-evaluates. The final commit also drops a script parameter that had no reader left.

This is what openjd-rs has always done, and what the Deadline worker agent adopted in public commit 08a5878b, "feat: forward the resolved symbol table to the v0 session", where it replaced extra_let_bindings outright. A convergent fix that adopts an existing upstream design, not a new one.

This PR no longer depends on #341 releasing

It did, and that is worth stating plainly because the earlier description said the opposite. Nothing in src/ passes path_format to evaluate_let_bindings any more, so this branch no longer calls an API that exists only on an unreleased openjd-model.

Verified in a virtual environment pinned to the released openjd-model==0.11.6: mypy src reports no issues across 40 source files. On that same released model, evaluate_let_bindings has no path_format parameter at all, which is what made the dependency real before and what makes its absence checkable now.

The companion model PR, OpenJobDescription/openjd-model-for-python#341, is still the other half of the behaviour — without it a step's bindings are still merged into script.let and this PR simply evaluates that merged list in the host's format, as today. But the two can now release in either order.

Migration

Removing the merge in the model half is a breaking change for any consumer that calls resolve_syntax_sugar() and does not forward a resolved symbol table to its session. Such a consumer silently loses step-level bindings rather than failing. The worker agent forwards; openjd-cli now does too, in OpenJobDescription/openjd-cli#237. A third-party consumer would not, and there is no in-process fallback, because no Python Step carries the resolved table.

Verification

Suite: 999 passed, 40 skipped, 16 xfailed, 0 failed.

Conformance through the branch CLI: 1172 passed, 2 failed. expr2.2.1--string-conversion and expr2.3.2--path-construction both pass — the two fixtures that pull in opposite directions, one wanting a value that has left path-space to stay /mnt/out and the other wanting a path to render in the host's format. The 2 failures are pre-existing range-normalization cases, unrelated to this change and blocked on a spec ruling.

Other suites: model 5496, cli 318.

ruff check and black --check clean at the CI-pinned versions (ruff 0.15, black 26).

Not verified

Windows. This PR's Windows CI legs have been fail-fast cancelled on every round, so Windows has never been exercised by CI here. The behaviour was proven by simulation instead: with the host format forced to Windows in-process, a path-typed binding renders \a\b while a value that has left path-space stays /mnt/out.

The conformance runner concatenates a fixture's output block with its output_<os> block, so on a POSIX host an output_windows block is never asserted. A green POSIX conformance run proves nothing about the Windows expectations.

An instantiated Step's `script.let` is a merged list: the step-level bindings
the template declared, followed by the script's own. The step-level prefix was
already evaluated at job creation in template scope, which openjd-rs — and
openjd-model as of the companion change — evaluate with `PathFormat::Posix` so
that a create-time result cannot depend on the host that created the job. The
runners re-evaluated the whole merged list in the host's format, so on Windows
the prefix's PATH values were re-rendered with backslashes and a binding
silently held a different value in the two evaluations:
`startswith(path("/foo/bar"), "/foo")` flipped from true to false.

`apply_script_let_bindings` now owns the split. The leading
`_template_scope_let_count` entries are evaluated with `PathFormat.POSIX` and
the remainder with the host's format, unchanged — a script's own bindings are
session scope and legitimately reference `Session.WorkingDirectory`,
`Task.File.*` and `apply_path_mapping`. Both halves go into the same symbol
table in the same order, so a script-level binding can still reference a
step-level one.

Applied at all three sites that evaluate a step script's merged list: the step
runner, the step runner's embedded-files path, and RFC 0008's
`_build_wrapped_inner_scope`. Environment scripts are untouched.

The count is read with `getattr(script, "_template_scope_let_count", 0)`, so an
openjd-model without the companion change degrades to the previous behaviour.
`path_format` is likewise forwarded to `evaluate_let_bindings` only when set,
because the parameter does not exist at the currently declared openjd-model
floor and passing it there raises TypeError rather than being ignored.

This is the openjd-sessions half of a two-repo fix; see
OpenJobDescription/openjd-model-for-python#341. The openjd-model floor in
pyproject.toml must be raised once that half releases, and until then
`hatch run typing` reports one error against the PyPI model.

Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
@leongdl
leongdl requested a review from a team as a code owner August 29, 2026 01:42
Comment thread src/openjd/sessions/_runner_base.py Outdated
Comment thread src/openjd/sessions/_runner_base.py Outdated
if path_format is None:
evaluate_let_bindings(symtab=symtab, let_bindings=let_bindings)
else:
evaluate_let_bindings(symtab=symtab, let_bindings=let_bindings, path_format=path_format)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The graceful-degradation design here means the fix is a silent no-op on the declared dependency floor. pyproject.toml pins openjd-model >= 0.11.6, < 0.12; on 0.11.6 neither path_format nor _template_scope_let_count exists, so getattr(..., 0) returns 0, the split never happens, and the Windows PATH-rendering divergence this PR exists to fix is still live. An installation that resolves to the floor gets none of the fix and no signal that it did not.

If the model-side half has shipped, the floor should be raised to that version and this two-branch forward + getattr fallback can collapse to the unconditional form the comment already anticipates. If it has not shipped yet, consider a follow-up marker so the floor bump is not forgotten — as written there is nothing in the tree that will fail when the model catches up.

Relatedly, the new tests do not degrade the way the source does: test_template_scope_let_split.py::test_path_format_is_load_bearing and TestEndToEndScopeAgreement::test_a_step_level_path_binding_agrees_across_the_two_evaluations call apply_let_bindings(..., path_format=...) directly, which raises TypeError on a floor-version model. So the suite already assumes a model newer than the declared 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.

Correct, and blocked rather than fixable here: released openjd-model 0.11.6 has neither path_format nor _template_scope_let_count, so on the declared floor the split never happens. The floor gets raised to the release carrying openjd-model#341, which is step 4 of the merge order and also clears the known mypy failure. Staying open until that releases.

Comment thread src/openjd/sessions/_runner_base.py Outdated
Comment thread src/openjd/sessions/_session.py Outdated
The previous commit evaluated the template-scope prefix with
`PathFormat.POSIX` and seeded the result directly. That is wrong, and on
Windows it is worse than the bug it replaced.

An EXPR path value carries its format, and reading one under a different format
is a hard error rather than a re-render:

    ExpressionError: Path format mismatch for 'root':
                     value has Posix but evaluator uses Windows

Action arguments, embedded-file `data` and environment-variable values all
resolve in the host's format, so a Posix-tagged path seeded into the session
table made every one of those reads raise. That would have broken
`EXPR/jobs/expr2.3.2--path-construction`, a conformance fixture that passes
today and asserts `STR:\a\b` on Windows.

The conformance suite states the rule precisely. A binding whose result leaves
path-space must freeze the text template scope produced, so
`expr2.2.1--string-conversion` wants `/mnt/out` on both platforms. A binding
whose result is still a path must render in the host's format, so
`expr2.3.2--path-construction` wants `\a\b` on Windows.

So `_apply_template_scope_let_bindings` now evaluates the prefix into a child
table with POSIX, then re-tags the results to the host's format through a
`SerializedSymbolTable` round trip before seeding them. That leaves a frozen
string alone and re-renders a live path, which is both halves of the rule. It
mirrors how a create-time table already reaches a session, via
`Session._resolved_base_entries` and its `to_symtab(path_format=host_format)`.

Verified with the host format forced to Windows: a step-level `path('/a/b')`
reads back as `\a\b`, `string(path('/mnt/out'))` stays `/mnt/out`,
`startswith(path('/foo/bar'), '/foo')` stays `true`, and a session-scope
binding is untouched.

Also from review: an out-of-range boundary now falls back to session scope
instead of being clamped into range. Clamping would evaluate a genuinely
session-scope binding in template scope; the fallback is the pre-fix behaviour,
which never mis-scopes. Two comments that over-claimed are corrected, one about
this module having a single crossing into `openjd.expr` and one about a branch
being unreachable when this PR's own tests reach it.

Suite: 1012 passed, 0 failed, 40 skipped, 16 xfailed.
Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
@leongdl

leongdl commented Aug 29, 2026

Copy link
Copy Markdown
Contributor Author

Pushed a correction. A local three-reviewer pass found that the first version of this change was wrong on Windows, worse than the bug it replaced, so please review from 32eafb2 rather than the original diff.

An EXPR path value carries its format, and reading one under a different format raises rather than re-rendering:

ExpressionError: Path format mismatch for 'root': value has Posix but evaluator uses Windows

Action args, embedded-file data and env-var values all resolve in the host's format, so seeding a Posix-tagged path made every one of those reads raise. That would have broken EXPR/jobs/expr2.3.2--path-construction, which passes today and asserts STR:\a\b on Windows.

The conformance suite states the rule: a binding whose result leaves path-space must freeze the text template scope produced (expr2.2.1--string-conversion wants /mnt/out on both platforms), and a binding whose result is still a path must render host format (expr2.3.2--path-construction wants \a\b on Windows). The prefix is now evaluated with POSIX into a child table and then re-tagged to the host format through a SerializedSymbolTable round trip, which satisfies both halves. That mirrors Session._resolved_base_entries, which already does to_symtab(path_format=host_format).

Companion correction in openjd-model-for-python#341 at 48e91a3: the boundary was recorded only by an after-validator on Step, so a consumer that resolves syntax sugar on a StepTemplate and runs the script directly never got it. The Deadline worker agent does exactly that, so the fix reached openjd-cli and not the worker. Measured before: create_job gave 2, the resolve_syntax_sugar path gave nothing. Both give 2 now.

Still unverified: Windows itself. This PR's Windows legs have not run to completion, and its CI stays red until #341 releases and the openjd-model floor here is raised.

Comment thread src/openjd/sessions/_runner_base.py Outdated
Comment thread src/openjd/sessions/_runner_base.py Outdated
Comment thread src/openjd/sessions/_runner_base.py Outdated
Comment thread src/openjd/sessions/_runner_base.py Outdated
The end-to-end test used count=1, leaving the second binding in session scope. On
a Windows host that binding reads its path neighbour in host format and evaluates
to false, which is the designed behaviour, so the test asserted true for a value
that is false there. It passed on POSIX and would have failed Windows CI the
first time those legs ran. Both step-level bindings belong in the prefix, which
is what the test is about.

Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
Comment thread src/openjd/sessions/_runner_base.py Outdated
Comment thread src/openjd/sessions/_runner_base.py Outdated
Comment thread src/openjd/sessions/_runner_base.py Outdated
Comment thread test/openjd/sessions_v0/test_template_scope_let_split.py Outdated
Comment thread src/openjd/sessions/_runner_base.py Outdated
"""
from openjd.expr import PathFormat, SerializedSymbolTable

from openjd.model._format_strings._expr_support import symtab_to_expr_values

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This reaches two levels into openjd-model's private API — openjd.model.**_format_strings**.**_expr_support**.symtab_to_expr_values — and it is the only such import in openjd/sessions/ (the two in _v1/ are openjd.model._v1.*, a versioned package, not an internal implementation module). Nothing about _format_strings._expr_support is covered by openjd-model's compatibility surface, so a rename or relocation in any 0.11.x patch breaks it.

The failure mode is what makes this more than a style point: an ImportError here is raised from inside apply_script_let_bindings, and neither caller catches it. _apply_let_bindings_or_fail catches ValueError only (line 1234), _materialize_files catches (RuntimeError, ValueError) (line 1216), and _try_inject_wrapped_symbols catches (FormatStringError, ValueError, RuntimeError) (_session.py:2077). So an openjd-model that moved this function turns every EXPR step-with-step-level-lets into an unhandled ImportError out of the public Session.run_task — not a failed action, and not the graceful degradation the rest of this function is carefully built around (getattr for the count, the two-branch forward for path_format).

Also worth noting the pinning gap: pyproject.toml:42 documents what the >= 0.11.6 floor is for by name (CancelationMethodDeferred, SymbolTable.expr_host_rules, ...). This PR adds three new model/engine requirements — evaluate_let_bindings(path_format=), symtab_to_expr_values, SerializedSymbolTable.from_symtab — and updates neither the floor nor that comment.

If openjd-model has (or could add) a public equivalent, using it would remove the coupling entirely. Failing that, catching ImportError alongside the range guard and degrading to the unsplit path would at least keep the "never raises out of the public API" property this function otherwise maintains.

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.

Correct and open: the floor gets raised to the release carrying openjd-model#341, which also clears the known mypy failure, and an ImportError guard goes in next to the range guard. Blocked on that release, so this stays open.

Comment thread src/openjd/sessions/_runner_base.py Outdated
"""
import os

from openjd.expr import PathFormat

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The updated _classify_expr_value docstring now says "one of two places in this module that import openjd.expr" and names _apply_template_scope_let_bindings as "the other crossing" — but this PR adds three: line 190, line 602, and this one. _host_path_format is a third, and it is the only one that is a standalone module-level function with no guard of its own, so the "structural instead of merely documented" property that paragraph is defending is now weaker than the paragraph claims.

Concretely: any future caller of _host_path_format() gets an unguarded import openjd.expr — which is exactly the class of regression test_import_purity.py exists to catch (per its module docstring, a load-time dependency on the native extension breaks a consumer that only runs non-EXPR templates). Today the only caller is inside the prefix guard, so the purity tests pass; nothing stops that from changing, and the docstring now reads as if it had been audited.

Two small things that would restore the invariant:

  • Either fold the PathFormat lookup into _apply_template_scope_let_bindings (which already imports PathFormat on line 602, so the helper adds no reuse), or update the docstring to say three and note that this one is guarded only by its single call site.
  • import os on line 573 shadows the module-level os already imported at line 4. Harmless but it reads as if os were unavailable at module scope, which would mislead a future reader about why the import is local (the openjd.expr import is the one that must be deferred; os is not).

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.

Correct: there are three crossings at this head, 190, 565 and 602, not two, and import os at 573 also shadows line 3. Bundled with the floor bump as one tidy-up and not in at this head, so leaving the thread open.

The prefix was evaluated with PathFormat.POSIX against a child of the
session symbol table, which holds host-format values. On a Windows host
that either raises `Path format mismatch` for a PATH job parameter or a
natively seeded create-time value, or silently succeeds against a
re-rendered one -- `.parent` of a Windows path read as POSIX is '.',
because a backslash is an ordinary POSIX path character. The previous
commit fixed the output side and left this input side broken.

The prefix now evaluates against only the symbols in scope that carry no
path format, plus the prefix bindings already bound. The filter tests
shape rather than name, because the set of session symbols grows and a
name denylist would rot: a symbol is excluded if its value is a native
path-typed engine value, or its expr_types entry declares PATH or
LIST[PATH]. That matches the measured blast radius exactly.

A prefix binding that needs an excluded symbol now fails with `Undefined
variable`, and the whole let list falls back to one host-format
evaluation. All-or-nothing on purpose: freezing per binding would leave a
POSIX-evaluated binding reading a host-evaluated sibling, which is the
same cross-format read somewhere less visible. The fallback is the
pre-fix behaviour exactly, so it cannot regress, and a genuine
evaluation error still surfaces from it with the same message.

This makes the change a smaller claim: it freezes self-contained
template-scope bindings and declines the rest. Reproducing the rest needs
the create-time value carried to the session rather than recomputed.

9 tests added, each mutation-checked against a revert of the production
change.

Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
Four assertions in this file compared a rendered value against a POSIX
literal. Three of them are wrong on Windows, not merely weaker: a binding
still holding a path is seeded in the host's format, so
`path('/foo/bar')` reads `\foo\bar` there and both texts are correct. The
Windows leg was red because of them, and fail-fast cancelled it before it
reported, so these assertions had never been judged on Windows at all.

The rule they now state: a binding whose result has left path-space
freezes the POSIX text it froze at job creation; a binding still holding
a path renders in the host's format.

`_as_the_host_renders` builds the expectation by evaluating the same
expression through the same machinery at the engine default -- which is
the host's format -- rather than hardcoding one literal per platform
behind a conditional. It reaches the answer by a different route than the
code under test, a direct host-format evaluation rather than a POSIX
evaluation re-tagged, so it is not asserting the code against itself.

Fixed:

- `root` and `under` in test_ordering_is_preserved_across_the_boundary.
  `under` is session scope and reads `root` in host format, so `false` on
  Windows is correct behaviour, not a defect.
- `inner["tmpl"]` in the RFC 0008 wrapped-inner-scope test.
- The end-to-end agreement test, where the *fixture* was at fault:
  `session_time` is re-tagged to the host and `create_time` came from a
  bare POSIX `apply_let_bindings` with no re-tag, so the two sides sat at
  different points of the journey a create-time table takes to reach a
  session. Both sides are now read at the same point, and the test still
  makes its point that they agree.

Verified on both legs, 25 passed each: unpatched, and with the host path
format and the engine default both forced to WINDOWS in-process. Three
production mutants confirm the corrected assertions pin behaviour --
dropping the re-tag is caught by all three on the Windows leg and by none
on POSIX, which is precisely the coverage this commit restores.

Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
Comment thread src/openjd/sessions/_runner_base.py Outdated
Comment thread src/openjd/sessions/_runner_base.py Outdated
Comment thread src/openjd/sessions/_runner_base.py Outdated
`_apply_template_scope_let_bindings` copied `symtab.expr_host_rules` into
the scratch table it evaluates the template-scope prefix against. The copy
was inert for every valid binding and the comment defending it was wrong.

`apply_path_mapping` is the only host-context function, and RFC 0005 bars
those from template scope: openjd-model invokes the create-time hook with
no host context, so a template-scope binding calling one raises
`Unknown function: 'apply_path_mapping'` at job creation and never had a
create-time value to reproduce. Measured separately, `expr_host_rules`
of `None` versus `[]` render `path().parent`, arithmetic and `join`
identically, so the copy changed nothing for a conforming template.

For a template that does violate RFC 0005 the copy was actively harmful:
it let the binding resolve here against the *host's* rules while
evaluating in POSIX, freezing a mixed-separator value such as
`C:\Users\test/bar` instead of declining. Dropping the copy makes such a
binding raise `Unknown function`, which the existing `except ValueError`
turns into the whole-list host-format fallback -- the documented
behaviour for a prefix that cannot be reproduced in template scope, and
the pre-fix value.

Two tests. The first pins the fallback (not the raise, which is internal)
for a prefix binding calling `apply_path_mapping`. The second guards the
derived-table shape the runners actually pass, `SymbolTable(source=...)`,
and pins openjd-model's `_expr_types` copy in `SymbolTable.__init__`: if
that is ever dropped, `declared_types` goes empty and the PATH filter
silently stops filtering.

Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
openjd-model no longer merges a step's template-scope `let` into
`script.let`. The step's bindings are resolved once at job creation and
travel in the step symbol table, which reaches a session through the
existing `resolved_symtab` parameter and `_resolved_base_entries`, so
`script.let` now holds only the script's own bindings. Those are
genuinely session scope and correctly evaluate in the host's format,
which is what they did before this branch.

That makes the prefix-splitting apparatus redundant, and worse than
redundant: with both mechanisms active the session-side re-evaluation
wrote last and clobbered the correctly-formatted seeded value. Remove
`_apply_template_scope_let_bindings`, `_is_format_neutral`,
`_type_carries_path_format`, `_host_path_format`, every read of
`_template_scope_let_count`, the caller logic that split the list, the
all-or-nothing fallback, and the private
`openjd.model._format_strings._expr_support` import that existed only
for this feature. `apply_script_let_bindings` is now a single
host-format evaluation of the whole list.

This also drops the last use of the `path_format` keyword argument to
`evaluate_let_bindings`, which does not exist on the released
openjd-model 0.11.6 that CI resolves from PyPI. `mypy src test` against
0.11.6 is now clean, so this branch no longer waits on the openjd-model
change releasing.

Replace the 27 tests of the deleted design, plus the import-purity
positive control that pinned its guarded `PathFormat` import, with four
tests of the behaviour that matters: a seeded create-time path binding
survives a script's own `let`, a step-level binding is never evaluated
at session time, and a script's own `let` still evaluates in the host's
format and still sees `Session.WorkingDirectory`. The Windows
simulation patches both seams that choose a path format, because
patching only the host-format derivation leaves the engine default at
POSIX and yields a host that cannot exist.

Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
Comment thread src/openjd/sessions/_runner_base.py Outdated
Comment thread test/openjd/sessions_v0/test_let_binding_scopes.py
Comment thread test/openjd/sessions_v0/test_let_binding_scopes.py
Comment thread test/openjd/sessions_v0/test_let_binding_scopes.py
Comment thread test/openjd/test_import_purity.py Outdated
`apply_script_let_bindings` took a `script` model object that nothing read.
It was threaded there through `ScriptRunnerBase._materialize_files`,
`ScriptRunnerBase._apply_let_bindings_or_fail` and
`Session._build_wrapped_inner_scope`, each of which carried the parameter
only to forward it, and each of which documented in a paragraph that it was
unused.

The parameter was live when it was introduced in dbfd1b5: a step script's
`let` list was then a merge of template scope and session scope, and the
script was read to find the boundary between them. 5c3c8c9 removed that
re-evaluation, so a script's `let` is now entirely its own scope and needs
no per-script information to evaluate. An unused parameter plus a paragraph
explaining that it is unused is worse than neither.

No behaviour change: the parameter had no reader on any path.

Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
Comment thread test/openjd/sessions_v0/test_let_binding_scopes.py
Comment thread src/openjd/sessions/_runner_base.py Outdated
The host-format test normalised path separators on only one side, so on
Windows CI, where both sides already render with backslashes, the
normalised left side no longer matched:

    assert 'C:/ProgramData/Amazon/OpenJD/60htba6m'
        == 'C:\ProgramData\Amazon\OpenJD\60htba6m'

`session.working_directory` is a real path object in the host OS's
flavour, while the binding renders in the format `_windows_host` forces.
Compare both sides through `PureWindowsPath` instead: the claim is
*which* path the binding saw, not how it renders, and the format claim
is the neighbouring `built` assertion. Reproduced on Windows 3.11, 3.12
and 3.14.

Also make the `apply_script_let_bindings` purity probe say something. It
used a malformed binding, which openjd-model skips without parsing, so
the evaluation path never ran and the assertion was near-tautological.
Split it in two: an empty `let` list must not load the native extension,
which is the production-reachable purity claim, and a valid non-path
binding (`mine = 1 + 1`, with its bound value asserted so a silent skip
fails) does load it. The latter is recorded as a documented limitation
rather than forced green, alongside
`test_path_mapping_rules_do_load_the_extension`.

Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
Comment thread test/openjd/test_import_purity.py Outdated
…ocstrings

apply_script_let_bindings was a pure pass-through to apply_let_bindings,
both public in __all__ with identical runtime behaviour. apply_let_bindings
was already public on mainline and this branch added the wrapper, so delete
the wrapper and keep the scope documentation -- the valuable part -- on
apply_let_bindings. Repoints the four production call sites (_session.py x2,
_runner_base.py x2) plus the test and comment references.

Also corrects two claims in test_let_binding_scopes.py that measurement did
not support:

- The module docstring said TestSeededStepValuesAreNotReEvaluated "is what
  fails if anyone reintroduces session-side re-evaluation". It does not: it
  builds the script's `let` list itself, so it cannot observe a re-merge.
  What it does pin is host-format deserialization of resolved_symtab --
  forcing _session.py's host_format to POSIX fails it. The re-merge half is
  pinned model-side by TestStepLetIsNotMergedIntoScript, whose six cases all
  fail against the pre-fix _model.py. The docstring now says both.

- _windows_host's mock_patch of openjd.sessions._session.os.name is
  process-wide, not module-scoped, because _session.py does `import os`
  (openjd.sessions._session.os is os). It is inert today -- os.name is read
  exactly once in _session.py, at the intended seam -- and a module-scoped
  patch is unavailable without changing that import, so the global scope is
  now noted for whoever next adds a call inside the block.

No behaviour change. Suite unchanged at 1000 passed, 40 skipped, 16 xfailed.

Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
Comment thread src/openjd/sessions/_runner_base.py
Comment thread test/openjd/sessions_v0/test_let_binding_scopes.py
The section comment and docstring said reaching apply_let_bindings with an
empty let list was "the session path for every script, EXPR or not" and a
"production-reachable purity claim". Neither holds: every call site guards on
truthiness first (_session.py, _runner_base._materialize_files, and both
callers of _apply_let_bindings_or_fail), so let_bindings=[] is a test-only
shape. The docstring's clause about the binding-length guard was also vacuous,
since that loop body never runs on an empty list.

Reword to what the test does pin: openjd-model's evaluate_let_bindings staying
pure on an empty list, as a dependency-boundary control. Point at the two tests
that already cover production purity for a non-EXPR script. Text only, no
behaviour change.

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