Skip to content
Merged
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
17 changes: 17 additions & 0 deletions src/openjd/sessions/_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -1653,6 +1653,23 @@ def _build_expr_host_rules(self) -> Optional[list[Any]]:
evaluation. Returns an empty list when the session has no rules (the
session is still host scope), or ``None`` when the engine bindings
are unavailable (pre-EXPR openjd-model)."""
if not self._path_mapping_rules:
# Nothing to convert, so no engine objects are needed and the import
# below is pure cost. Returning the empty list here is exactly what
# the loop produced anyway, and it keeps a session that never
# evaluates an EXPR expression from loading the native extension at
# all -- ``import openjd.expr`` is a facade over
# ``openjd._openjd_rs``, and ``__init__`` calls this
# unconditionally, so without this the deferral won by the
# module-level import removal was only from import time to
# first-session time.
#
# ``[]`` and not ``None``: the session is still host scope, so
# ``apply_path_mapping()`` must remain available with an empty rule
# set. ``SymbolTable.expr_host_rules`` stores this untouched
# (``Optional[list[Any]]``) and only crosses into Rust at evaluation
# time, so seeding ``[]`` costs nothing here.
return []

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 empty-rules fast path changes behavior for one case: no rules + openjd.expr unavailable (pre-EXPR openjd-model, or the native extension not built for the platform).

  • Before: the try/except ImportError ran even with no rules, so this case returned None.
  • After: it returns [] unconditionally.

Two things worth confirming:

  1. The docstring is now stale — it still says the method "returns … None when the engine bindings are unavailable (pre-EXPR openjd-model)", but with no rules that path is no longer reachable; None now only happens with rules present + bindings missing.
  2. _expr_host_rules is assigned straight onto symtab.expr_host_rules (line 1621) and crosses into Rust at evaluation time. Within this repo nothing distinguishes None from [] (no is None checks on it), so this looks safe — but please confirm openjd-model's SymbolTable / the Rust boundary treats an empty-list host-rules context the same as an unset/None one for a pre-EXPR build, so seeding [] there cannot trigger host-context construction that None previously avoided.

If both hold, a one-line docstring tweak so the None-return note reflects that it is now the rules-present case only would keep it accurate.

try:
from openjd.expr import PathFormat as ExprPathFormat
from openjd.expr import PathMappingRule as ExprPathMappingRule
Expand Down
126 changes: 126 additions & 0 deletions test/openjd/test_import_purity.py
Original file line number Diff line number Diff line change
Expand Up @@ -281,3 +281,129 @@ def test_pure_path_module_does_not_load_native_extension(tmp_path: Path, module:

# THEN
assert loaded == "False", f"importing {module} loaded the native extension"


# ---------------------------------------------------------------------------
# Import-time purity is not enough on its own: `Session.__init__` calls
# `_build_expr_host_rules()` unconditionally, so before the empty-rules fast
# path a worker that never evaluates an EXPR expression still loaded the
# extension on its FIRST SESSION. The deferral won by removing the module-level
# import was only from import time to first-session time.
# ---------------------------------------------------------------------------


class TestSessionLifecycleStaysExtensionFree:
def test_constructing_a_session_without_path_mapping_rules_stays_pure(
self, tmp_path: Path
) -> None:
"""The regression: `Session()` used to load the extension to build an
empty engine rules list."""
# WHEN
loaded = _run_probe(
tmp_path,
"""
import uuid

from openjd.sessions import Session

session = Session(session_id=uuid.uuid4().hex, job_parameter_values={})
try:
# `[]`, not `None`: the session is still host scope, so
# apply_path_mapping() stays available with an empty rule set.
assert session._expr_host_rules == [], session._expr_host_rules
print(RS in sys.modules)
finally:
session.cleanup()
""",
)

# THEN
assert loaded == "False", (
"constructing a Session with no path mapping rules loaded the native "
"extension; the empty-rules fast path in _build_expr_host_rules is "
"what prevents this."
)

def test_running_a_non_expr_task_stays_pure_end_to_end(self, tmp_path: Path) -> None:
"""The property a non-EXPR worker actually cares about: a whole task
runs without the extension ever being loaded."""
# WHEN
loaded = _run_probe(
tmp_path,
"""
import time
import uuid

from openjd.model.v2023_09 import ModelParsingContext, StepScript
from openjd.sessions import ActionState, Session, SessionState

context = ModelParsingContext(supported_extensions=["FEATURE_BUNDLE_1"])
script = StepScript.model_validate(
{"actions": {"onRun": {"command": "echo", "args": ["hello"]}}},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

run_task resolves the command via shutil.which (_win32/_locate_executable.py:66), and on Windows echo is a cmd.exe builtin with no standalone echo.exe on PATH — so shutil.which("echo") returns None and the launch raises RuntimeError("Could not find executable file: echo"). The probe then exits non-zero and _run_probe's assert completed.returncode == 0 fails.

The rest of this file goes out of its way to support Windows (see _probe_python), and the existing suite already handles exactly this: test_runner_base.py:835 uses "echo" if is_posix() else "cmd.exe". This end-to-end test uses echo unconditionally, so test_running_a_non_expr_task_stays_pure_end_to_end looks like it will fail on Windows. Consider selecting the command per-platform (e.g. cmd.exe /c echo hello).

context=context,
)
session = Session(session_id=uuid.uuid4().hex, job_parameter_values={})
try:
session.run_task(step_script=script, task_parameter_values={})
deadline = time.time() + 15
while session.state == SessionState.RUNNING and time.time() < deadline:
time.sleep(0.05)
status = session.action_status
assert status is not None and status.state == ActionState.SUCCESS, status
print(RS in sys.modules)
finally:
session.cleanup()
""",
)

# THEN
assert loaded == "False", (
"running a non-EXPR task loaded the native extension somewhere in the "
"session lifecycle"
)

def test_path_mapping_rules_do_load_the_extension(self, tmp_path: Path) -> None:
"""Negative control, and a documented limitation rather than a goal.

Real rules must be converted into ``openjd.expr.PathMappingRule`` engine
objects to seed the session's host context, so this case genuinely loads
the extension. openjd-sessions cannot avoid it alone: closing it needs
openjd-model to accept unconverted rules and convert them at the Rust
boundary, where ``symtab_to_expr_values`` already crosses.

Asserted so that the limitation is visible and so a future change that
makes this pure is noticed here rather than passing silently.
"""
# WHEN
loaded = _run_probe(
tmp_path,
"""
import uuid
from pathlib import PurePosixPath

from openjd.sessions import PathFormat, PathMappingRule, Session

rule = PathMappingRule(
source_path_format=PathFormat.POSIX,
source_path=PurePosixPath("/mnt/source"),
destination_path=PurePosixPath("/mnt/dest"),
)
session = Session(
session_id=uuid.uuid4().hex,
job_parameter_values={},
path_mapping_rules=[rule],
)
try:
assert len(session._expr_host_rules) == 1, session._expr_host_rules
print(RS in sys.modules)
finally:
session.cleanup()
""",
)

# THEN
assert loaded == "True", (
"a session with real path mapping rules is expected to load the "
"extension; if this is now False the limitation has been fixed and "
"this control should become a purity assertion"
)
Loading