Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion src/openjd/sessions/_runner_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,25 @@ def apply_let_bindings(*, symtab: SymbolTable, let_bindings: list[str]) -> None:
``Env.File.*``/``Task.File.*`` and a file's ``data`` may reference
let-bound values (mirroring openjd-rs's runner ordering).

Every binding in ``let_bindings`` is session scope, so there is one scope
here and one format: PATH-typed results render in the engine's default
format, which is the host's. A step's *template*-scope ``let`` does not
appear in this list — openjd-model resolves it once at job creation and its
values travel to the session in the step symbol table, reaching ``symtab``
through ``Step.resolved_symtab``

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 names Step.resolved_symtab as the route a template-scope value takes into a session, but that route is opt-in and unenforced — and the guidance at the public API that controls it is now stale in a way this paragraph makes visible.

run_task(resolved_symtab=...) defaults to None (_session.py:1242), and its docstring says "None is fine when the script has no let bindings and no expression interpolation that depends on step-scope state" (_session.py:1274-1275). That was true while openjd-model merged a step's let into script.let: the values were carried by the list, so a caller who passed nothing still got them (host-rendered, which is the bug this branch fixes). Once the merge is gone — which _expr_step_script's docstring in the new test file states as fact — the two clauses come apart. A step-level let is now invisible unless resolved_symtab is supplied, regardless of whether the script has a let of its own, so a caller following that sentence literally (script has no let → pass None) loses every step-level binding.

The consequence is not a wrong render but a hard failure: the name is simply undefined, so {{ step_out }} in an action arg raises FormatStringError and the action fails. Net effect of the two-repo change for a caller that never adopted resolved_symtab is Windows-mis-rendered-but-working → broken, with nothing in this package signalling the new requirement.

Since the docstring here is establishing the division as the contract, worth updating run_task/enter_environment's resolved_symtab guidance in the same pass to say None is only safe when neither the script nor its step declares a let. enter_environment (:790-795) has the same wording.

(:meth:`Session._resolved_base_entries`) already resolved and deserialized
into the host's format.

That division matters because the two are not interchangeable. A
template-scope value is frozen at creation with ``PathFormat::Posix`` so it
cannot depend on the host that created the job, and re-deriving one here
would re-render its PATH values — on Windows

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 stated mechanism is not what actually distinguishes the two routes, and this PR's own test says so.

_resolved_base_entries deserializes the create-time table with to_symtab(path_format=host_format) (_session.py:1788-1789), so a template-scope PATH value is re-rendered into the host's format on the seeded route too — the paragraph above this one says exactly that ("already resolved and deserialized into the host's format"), and test_let_binding_scopes.py encodes it: _SEEDED_POSIX_TEXT = "/foo/bar" goes in and _SEEDED_WINDOWS_TEXT = r"\foo\bar" comes out, with the comment "once deserialized in a Windows host's format, which is how a session must read it".

So "re-deriving one here would re-render its PATH values" cannot be the harm: re-rendering happens either way, and a value that is still a path renders host-format by design (EXPR/jobs/expr2.3.2--path-construction requires it). What re-derivation would actually change is a value that has already left path space — the derived result of a create-time expression. That is precisely what the example on the next line shows: startswith(path("/foo/bar"), "/foo") was computed at creation under POSIX and transported as the boolean true; re-evaluating the expression on a Windows host recomputes it against a host-format \foo\bar and yields false. The boolean flips, not the path rendering.

Worth rewording so the mechanism matches the example, e.g. "re-deriving one here would re-evaluate its expression against host-format inputs — on Windows startswith(path("/foo/bar"), "/foo") was computed as true at creation and recomputes to false". As written a reader who checks the claim against _resolved_base_entries finds it contradicted, and may conclude that route is the buggy one (which is R12, already rejected).

``startswith(path("/foo/bar"), "/foo")`` flips from ``true`` to ``false``.
Both a seeded value and a re-evaluated one would land in this same table, so
Comment thread
leongdl marked this conversation as resolved.
a re-evaluation would also *win*, overwriting the correctly-formatted seeded
value. Nothing in a session re-evaluates a step's bindings; it reads the
resolved ones.

Raises:
ValueError (FormatStringError/ExpressionError): if a binding's
expression cannot be evaluated, or if a binding is too long to
Expand All @@ -527,7 +546,10 @@ def apply_let_bindings(*, symtab: SymbolTable, let_bindings: list[str]) -> None:
f"which exceeds the maximum of {MAX_LET_BINDING_LENGTH}"
)
# Single-sourced in openjd.model (parse-memoized; skips malformed
# bindings; raises ValueError naming the failing binding).
# bindings; raises ValueError naming the failing binding). No `path_format`
# kwarg: the engine default is the host's format, which is the only format a
# session evaluates in, and the parameter does not exist on openjd-model at
# this package's declared floor (>= 0.11.6).
evaluate_let_bindings(symtab=symtab, let_bindings=let_bindings)


Expand Down
4 changes: 4 additions & 0 deletions src/openjd/sessions/_runner_step_script.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,10 @@ def run(self) -> None:
# the script's EXPR `let` bindings evaluate (so bindings can reference
# Task.File.*), and contents are written after (so `data` can
# reference let-bound values) — mirroring the openjd-rs runner.
#
# This `let` list is the script's own, and is entirely session scope. A
# step's template-scope `let` is resolved at job creation and arrives
# through `Step.resolved_symtab` instead; see apply_let_bindings.
if self._script.embeddedFiles is not None:
symtab = SymbolTable(source=self._symtab)
self._materialize_files(
Expand Down
5 changes: 5 additions & 0 deletions src/openjd/sessions/_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -2031,6 +2031,11 @@ def _build_wrapped_inner_scope(
symmetrically, the inner entity's lets never apply to the hook's own
resolution scope. Mirrors openjd-rs's ``build_wrapped_inner_scope``.

A script's own ``let`` is session scope here exactly as it is in the
runners, so a wrapped action resolves against the same scope it would
have had unwrapped, which is the property this method exists to
reproduce.

Raises:
ValueError (FormatStringError/ExpressionError): a binding or file
reference did not resolve.
Expand Down
291 changes: 291 additions & 0 deletions test/openjd/sessions_v0/test_let_binding_scopes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,291 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.

"""A session evaluates exactly one scope of ``let`` bindings: the script's own.

A step's *template*-scope ``let`` is resolved once at job creation, with
``PathFormat::Posix`` so a create-time value cannot depend on the host that
created the job. Those resolved values reach a session in the step symbol table
(``Step.resolved_symtab``) and are seeded by
:meth:`Session._resolved_base_entries`, deserialized into the host's format. A
script's own ``let`` is session scope and is evaluated here, in the host's
format, against the live session symbols.

The two must not be confused, and the failure mode is asymmetric. Both a seeded
value and a session-time re-evaluation land in the *same* symbol table, so when
both happen the re-evaluation writes **last** and clobbers the correctly
formatted seeded value. That overwrite is the bug these tests exist to prevent.

What :class:`TestSeededStepValuesAreNotReEvaluated` pins, measured rather than
assumed, is the *host-format deserialization* of ``resolved_symtab``: forcing
:mod:`openjd.sessions._session`'s ``host_format`` to POSIX fails it. It does not
by itself fail if the model starts re-merging a step's bindings into the script,
because it builds the script's ``let`` list itself rather than getting one from
job creation. That other half is pinned model-side, by
``TestStepLetIsNotMergedIntoScript`` in
Comment thread
leongdl marked this conversation as resolved.
``test/openjd/model_v0/v2023_09/test_let_bindings.py``, whose six cases all fail
against the pre-fix ``_model.py``. Together the two cover the clobber; neither
covers it alone.

On simulating a Windows host. A POSIX host renders both scopes identically, so a
value comparison here proves nothing about format on this machine -- it would
pass whatever the code did. ``_windows_host`` forces the other format, and it
patches **both** seams that choose one:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

There is a third seam that chooses a path format, and unlike the two listed it cannot be reached by patching os.name at all — so the "both seams" completeness claim is not quite right, and the self-inconsistency this paragraph warns about is still present in a latent form.

_path_mapping.py:6 does from os import name as os_name, binding the value at import time, and to_host_path_separators tests that snapshot (_path_mapping.py:99). mock_patch("openjd.sessions._session.os.name", "nt") rebinds the attribute on the shared os module, which _os_checker.is_posix() and _resolved_base_entries read live — but _path_mapping.os_name was already copied and stays "posix" for the whole process.

Session._symbol_table renders every PATH / LIST[PATH] parameter through that function (_session.py:1697, processed_parameter_value), so inside _windows_host() a Param.* PATH value keeps forward slashes while resolved_symtab entries render \foo\bar — exactly the arrangement lines 40-43 say "cannot occur in production".

Inert today only because every test in the file passes job_parameter_values={}. But the docstring is written as a fidelity guarantee for whoever extends these tests, and the natural next case — a seeded step binding that references a PATH job parameter, which is a large share of real step-level let bindings — would silently run against a POSIX-spelled Param.* under a Windows evaluator, i.e. C:/dest/out parsed as Windows or a genuinely Windows-mapped value never produced. Since _path_mapping snapshots the name, _windows_host would need to patch openjd.sessions._path_mapping.os_name as well.

Worth either adding that patch or narrowing the claim to "the two seams these tests reach", and naming the snapshot as the reason a third exists.


- ``openjd.sessions._session.os.name``, which
:meth:`Session._resolved_base_entries` reads to pick the format it
deserializes a create-time table with; and
- ``ExprNode._evaluate_raw``'s ``path_format=None`` default, which is the engine
default and is POSIX on this host.

Patching only the first is not a Windows host, it is a self-inconsistent one:
seeded values would render Windows while a script's own ``let`` still rendered
POSIX, and a test built on that would be asserting an arrangement that cannot
occur in production.
"""

from __future__ import annotations

import json
import uuid
from contextlib import contextmanager
from pathlib import PureWindowsPath
from typing import Any, Generator, Optional
from unittest.mock import patch as mock_patch

import pytest

from openjd.expr import PathFormat, SerializedSymbolTable
from openjd.model import SpecificationRevision, SymbolTable, evaluate_let_bindings
from openjd.model._format_strings._nodes import ExprNode
from openjd.model.v2023_09 import (
ModelParsingContext as ModelParsingContext_2023_09,
StepScript as StepScript_2023_09,
)
from openjd.sessions import Session
from openjd.sessions._runner_base import apply_let_bindings

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

_SEEDED_NAME = "step_out"
"""The name a step-level (template-scope) ``let`` binding resolved to at job
creation, arriving in the session's ``resolved_symtab``."""

_SEEDED_POSIX_TEXT = "/foo/bar"
"""The create-time value, as the service serialized it."""

_SEEDED_WINDOWS_TEXT = r"\foo\bar"
"""The same value once deserialized in a Windows host's format, which is how a
session must read it. Distinct from ``_SEEDED_POSIX_TEXT``, which is what a
session would show if the host-format deserialization were skipped."""


@contextmanager
def _windows_host() -> Generator[None, None, None]:
"""Force a Windows path format at both seams that decide one.

See this module's docstring for why one seam is not enough.

Note the scope of the ``os.name`` patch: ``_session.py`` does ``import os``,
so ``openjd.sessions._session.os`` *is* the ``os`` module and patching the
attribute is **process-wide**, not module-scoped. It is inert today because
``os.name`` is read exactly once in ``_session.py``, at the seam this is
aiming at, and nothing else runs inside the block. A module-scoped patch is
not available without changing that import, so if you add a call inside this
context manager, check first that it does not read ``os.name`` for an
unrelated reason.
"""
original = ExprNode._evaluate_raw

def _evaluate_raw_windows(
self: ExprNode, *, symtab: SymbolTable, path_format: Any = None
) -> Any:
# Substitute only the *default*. An explicit format from a caller is
# left alone, so this stands in for the engine default rather than
# overriding evaluation everywhere.
if path_format is None:
path_format = PathFormat.WINDOWS
return original(self, symtab=symtab, path_format=path_format)

with mock_patch("openjd.sessions._session.os.name", "nt"):
Comment thread
leongdl marked this conversation as resolved.
with mock_patch.object(ExprNode, "_evaluate_raw", _evaluate_raw_windows):
Comment thread
leongdl marked this conversation as resolved.
yield


def _serialized_table(entries: list[dict[str, str]]) -> SerializedSymbolTable:
"""Build a SerializedSymbolTable from its wire (JSON) form -- the same shape
the service serves as ``resolvedSymbolTable``."""
return SerializedSymbolTable.from_json_str(json.dumps(entries))


def _seeded_step_table() -> SerializedSymbolTable:
"""A create-time table carrying one path-valued step-level ``let`` result."""
return _serialized_table([{"name": _SEEDED_NAME, "type": "path", "value": _SEEDED_POSIX_TEXT}])


def _expr_step_script(let: list[str]) -> StepScript_2023_09:
"""A step script whose ``let`` is its own -- the only thing a script's ``let``
field carries now that openjd-model no longer merges a step's bindings into
it."""
context = ModelParsingContext_2023_09(supported_extensions=["EXPR"])
return StepScript_2023_09.model_validate(
{"let": let, "actions": {"onRun": {"command": "echo", "args": ["ok"]}}},
context=context,
)


def _spy_on_evaluation() -> Any:
"""Patch the model's ``evaluate_let_bindings`` where openjd-sessions imports
it, recording every call while still evaluating for real.

Spying here rather than on ``apply_let_bindings`` keeps the real evaluation
in the loop, so a test can assert both the calls and the resulting values.
"""
return mock_patch(
"openjd.sessions._runner_base.evaluate_let_bindings",
side_effect=evaluate_let_bindings,
)


def _evaluated_bindings(spy: Any) -> list[str]:
"""Every binding string handed to the evaluator, flattened across calls."""
return [b for call in spy.call_args_list for b in call.kwargs["let_bindings"]]


def _session_symtab(
session: Session,
*,
resolved_symtab: Optional[SerializedSymbolTable] = None,
) -> SymbolTable:
"""The session-scope symbol table a script would be resolved against.

Built through the session's own ``_resolved_base_entries`` /
``_symbol_table`` rather than end to end through ``run_task``, because
``_windows_host`` patches the process-wide ``os.name`` and running a real
subprocess under that would exercise Windows user and path handling on a
POSIX host -- unrelated machinery, and not what these tests are about.
"""
resolved_base = (
session._resolved_base_entries(resolved_symtab) if resolved_symtab is not None else None
)
return session._symbol_table(
SpecificationRevision.v2023_09,
resolved_base=resolved_base,
)


# ---------------------------------------------------------------------------
# The regression test for the overwrite bug.
# ---------------------------------------------------------------------------


class TestSeededStepValuesAreNotReEvaluated:
"""A create-time value seeded from ``resolved_symtab`` must survive a script
that has its own ``let``. This is the test that fails if session-side
re-evaluation of a step's bindings is reintroduced."""

def test_a_seeded_path_binding_survives_a_scripts_own_let(self) -> None:
# GIVEN: a Windows host, a create-time table carrying a path-valued
# step-level binding, and a script with a `let` of its own.
script = _expr_step_script(["mine = 1 + 1"])
with Session(session_id=uuid.uuid4().hex, job_parameter_values={}) as session:
with _windows_host():
symtab = _session_symtab(session, resolved_symtab=_seeded_step_table())
Comment thread
leongdl marked this conversation as resolved.
# The seeded value is in host format before the script's `let`
# runs; the assertion after is that it is still there.
assert str(symtab[_SEEDED_NAME]) == _SEEDED_WINDOWS_TEXT

# WHEN
with _spy_on_evaluation() as spy:
apply_let_bindings(symtab=symtab, let_bindings=script.let or [])

# THEN: the seeded value is untouched, in the host's format.
assert str(symtab[_SEEDED_NAME]) == _SEEDED_WINDOWS_TEXT, (
"the seeded create-time value was overwritten. A session must "
"read a step's resolved bindings, never re-derive them: a "
"re-evaluation lands in this same table and so wins."
)
# AND: the script's own binding did land.
assert symtab["mine"].item() == 2
# AND: nothing re-evaluated the seeded name. This is the half of
# the assertion that a value comparison cannot make -- on a
# faithful Windows host a re-evaluation of the same expression
# would render the same text, so only the absence of the call
# distinguishes "seeded" from "recomputed".
assert _evaluated_bindings(spy) == ["mine = 1 + 1"]

def test_a_step_level_binding_is_not_evaluated_at_session_time(self) -> None:
# GIVEN: a create-time table whose step-level binding is *also* named in
# nothing the script declares -- the shape openjd-model now produces,
# where `script.let` holds only the script's own bindings.
script = _expr_step_script(["mine = 'x'"])
with Session(session_id=uuid.uuid4().hex, job_parameter_values={}) as session:
symtab = _session_symtab(session, resolved_symtab=_seeded_step_table())

# WHEN
with _spy_on_evaluation() as spy:
apply_let_bindings(symtab=symtab, let_bindings=script.let or [])

# THEN: the evaluator saw the script's own bindings and nothing else.
evaluated = _evaluated_bindings(spy)
assert evaluated == ["mine = 'x'"]
assert not any(b.split("=")[0].strip() == _SEEDED_NAME for b in evaluated), (
f"a step-level binding ({_SEEDED_NAME}) was evaluated at session "
"time; it is resolved at job creation and only read here"
)


# ---------------------------------------------------------------------------
# A script's own `let` is session scope: host format, live session symbols.
# ---------------------------------------------------------------------------


class TestAScriptsOwnLetIsSessionScope:
def test_it_evaluates_in_the_host_format_and_sees_session_symbols(self) -> None:
# GIVEN: a Windows host and a script whose own `let` both builds a path
# (so the format is observable) and reads a session symbol (so the
# session scope is observable).
script = _expr_step_script(
[
"built = path('/a/b')",
"wd = Session.WorkingDirectory",
]
)
with Session(session_id=uuid.uuid4().hex, job_parameter_values={}) as session:
with _windows_host():
symtab = _session_symtab(session)

# WHEN
apply_let_bindings(symtab=symtab, let_bindings=script.let or [])

# THEN: the path rendered in the *host's* format, not POSIX.
assert str(symtab["built"]) == r"\a\b", (
Comment thread
leongdl marked this conversation as resolved.
"a script's own `let` is session scope and must render in the "
"host's path format"
)
# AND: it resolved against the live session symbol table.
# `Session.WorkingDirectory` is PATH-typed, so under the forced
# Windows format it renders with backslashes -- while
# `session.working_directory` is a real path object in the *host
# OS's* flavour, which is POSIX here and Windows on CI. The claim
# is *which* path the binding saw, not how it renders, so both
# sides are compared as paths rather than as text.
# `PureWindowsPath` is the right parser for the rendered side
# because the format was forced to Windows; it also accepts `/`
# as a separator, so a POSIX `working_directory` parses to the
# same parts. The format claim is the `built` assertion above.
assert PureWindowsPath(str(symtab["wd"])) == PureWindowsPath(
session.working_directory
)

def test_a_failing_binding_still_raises(self) -> None:
"""Negative control for the two tests above: the evaluation is real, so a
broken binding is still an error rather than being silently skipped."""
script = _expr_step_script(["bad = Undefined.Symbol"])
with Session(session_id=uuid.uuid4().hex, job_parameter_values={}) as session:
symtab = _session_symtab(session)

# WHEN / THEN
with pytest.raises(ValueError, match="bad"):
apply_let_bindings(symtab=symtab, let_bindings=script.let or [])
Loading
Loading