Skip to content

Commit 49d1804

Browse files
committed
refactor: Put every openjd.expr crossing behind the one sys.modules guard
Addresses jericht's review on PR #341, which asked _is_expr_list to guard on sys.modules before importing openjd.expr, the way _is_expr_null does. Adding that guard literally would have been dead code: _is_expr_list had one call site, reached only after _as_expr_value had already returned non-None (which required the extension loaded) and after .is_null had been read off the value. Instrumenting it confirmed this -- 4 real calls, all with an ExprValue, all with the extension already loaded, none where the guard would fire. Worse, its `return False` would mean "not a list", so if it ever did fire the caller would stringify an EXPR list instead of flattening it into separate argv entries: a silently wrong answer, not a safe fallback. The concern underneath is real though. The asymmetry was hard to read, and a future caller reaching _is_expr_list with an arbitrary value would have loaded the extension through an unguarded import -- reintroducing the very leak ce863b7 fixed. So remove the asymmetry instead of documenting it. _as_expr_value and _is_expr_list are replaced by a single _classify_expr_value returning an _ExprKind (NOT_EXPR / NULL / LIST / SCALAR). It is now the only function in the module that imports openjd.expr, and it sits behind the one guard, so the property is structural rather than a docstring promise. _is_expr_null becomes a one-line wrapper over it. Behaviour is unchanged and the existing tests pass unmodified: 909 before, 909 after, same set. Mutation-checked the new dispatch seam -- dropping or weakening the guard, suppressing each of the four arms, misclassifying SCALAR as LIST or NULL, and breaking either call-site arm are all caught by name. Two notes: - Classifying SCALAR as LIST initially SURVIVED. A scalar ExprValue is not iterable, so that misclassification raises TypeError out of the runner, and no test passed a scalar whole-field EXPR argument through resolve_action_arg_values -- list and null coverage left that arm open. Adds test_let_bound_scalar_becomes_exactly_one_argument to close it. - Inverting the NULL/LIST check order survives and should: a null value is TypeCode.NULLTYPE and an empty list is not null, so the two conditions are mutually exclusive and the order cannot matter. Verified rather than assumed; recorded here as an equivalent mutant, not a gap. Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
1 parent 91c07f3 commit 49d1804

2 files changed

Lines changed: 88 additions & 43 deletions

File tree

src/openjd/sessions/_runner_base.py

Lines changed: 54 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from concurrent.futures import Future, ThreadPoolExecutor
1111
from dataclasses import dataclass
1212
from datetime import datetime, timedelta, timezone
13-
from enum import Enum
13+
from enum import Enum, auto
1414
from pathlib import Path
1515
from threading import Lock, Timer
1616
from typing import Any, Callable, Literal, Optional, Sequence, Type, cast
@@ -145,47 +145,59 @@ def _over_range_message(description: str, value: int) -> str:
145145
_EXTENSION_MODULE = "openjd._openjd_rs"
146146

147147

148-
def _as_expr_value(value: Any) -> Optional[Any]:
149-
"""``value`` if it is the EXPR engine's typed value, else ``None``.
148+
class _ExprKind(Enum):
149+
"""How a resolved format-string value relates to the EXPR type system."""
150+
151+
NOT_EXPR = auto()
152+
"""Not an ``ExprValue``: a plain string, or a legacy interpolation's own
153+
value. Resolves to its string form; typed semantics do not apply."""
154+
155+
NULL = auto()
156+
"""The engine's typed null -- "field omitted" / "argument skipped"."""
157+
158+
LIST = auto()
159+
"""A list, whose elements flatten to one argument each (RFC 0005 §1.3.2)."""
160+
161+
SCALAR = auto()
162+
"""Any other typed value; resolves to its string form."""
163+
164+
165+
def _classify_expr_value(value: Any) -> _ExprKind:
166+
"""Classify ``value`` against the EXPR type system.
167+
168+
This is the *only* place in this module that imports ``openjd.expr``, so
169+
that every crossing into the native extension sits behind the one
170+
``sys.modules`` guard below. Doing the whole classification here rather
171+
than exposing a separate "is it a list" predicate keeps that property
172+
structural instead of merely documented: there is no second, unguarded
173+
entry point for a future caller to reach with an arbitrary value.
150174
151175
``ExprValue`` instances are created only by the native extension, so if
152176
that extension has not been loaded then ``value`` cannot be one and the
153-
answer is ``None`` without importing anything. Once it *has* been loaded --
154-
i.e. an EXPR expression has been evaluated in this process -- the import
155-
below is a ``sys.modules`` hit.
177+
answer is ``NOT_EXPR`` without importing anything. Once it *has* been
178+
loaded -- i.e. an EXPR expression has been evaluated in this process --
179+
the import is a ``sys.modules`` hit.
156180
157-
``ExprValue`` is then imported concretely (rather than duck-typed with
181+
``ExprValue`` is imported concretely (rather than duck-typed with
158182
``getattr``) so that a model API change fails loudly here instead of
159183
silently mis-classifying every optional integer field as "omitted".
160-
161-
Returning the value rather than a bool matters at the call sites: typed
162-
null/list handling applies *only* to an ``ExprValue``, and anything else
163-
must fall through to plain string resolution rather than have ``.is_null``
164-
read off it.
165184
"""
166185
if _EXTENSION_MODULE not in sys.modules:
167-
return None
168-
from openjd.expr import ExprValue
186+
return _ExprKind.NOT_EXPR
187+
from openjd.expr import ExprValue, TypeCode
169188

170-
return value if isinstance(value, ExprValue) else None
189+
if not isinstance(value, ExprValue):
190+
return _ExprKind.NOT_EXPR
191+
if value.is_null:
192+
return _ExprKind.NULL
193+
if value.type.type_code == TypeCode.LIST:
194+
return _ExprKind.LIST
195+
return _ExprKind.SCALAR
171196

172197

173198
def _is_expr_null(value: Any) -> bool:
174199
"""True if ``value`` is the EXPR engine's typed null."""
175-
expr_value = _as_expr_value(value)
176-
return expr_value is not None and bool(expr_value.is_null)
177-
178-
179-
def _is_expr_list(expr_value: Any) -> bool:
180-
"""True if ``expr_value`` is an EXPR list value, whose elements flatten
181-
into one argument each (RFC 0005 §1.3.2).
182-
183-
Takes an already-identified ``ExprValue``, so the extension is loaded by
184-
definition here.
185-
"""
186-
from openjd.expr import TypeCode
187-
188-
return bool(expr_value.type.type_code == TypeCode.LIST)
200+
return _classify_expr_value(value) is _ExprKind.NULL
189201

190202

191203
def _timeout_from_seconds(seconds: int, logger: LoggerAdapter) -> Optional[timedelta]:
@@ -292,22 +304,21 @@ def resolve_action_arg_values(args: Optional[Sequence], symtab: SymbolTable) ->
292304
# argument is genuinely unresolvable.
293305
resolved.append(arg.resolve(symtab=symtab))
294306
continue
295-
expr_value = _as_expr_value(value)
296-
if expr_value is None:
297-
# Not a typed EXPR result: a plain string, or -- for a legacy
298-
# (non-EXPR) whole-field interpolation -- the symbol's own
299-
# value, which may be any Python type a caller put in the
300-
# symbol table (an ``int`` for an INT job parameter, a ``bool``
301-
# for a BOOL one). Typed null/list semantics exist only under
302-
# EXPR whole-field resolution, so everything here resolves to
303-
# its string form.
307+
kind = _classify_expr_value(value)
308+
if kind is _ExprKind.NOT_EXPR:
309+
# A plain string, or -- for a legacy (non-EXPR) whole-field
310+
# interpolation -- the symbol's own value, which may be any
311+
# Python type a caller put in the symbol table (an ``int`` for
312+
# an INT job parameter, a ``bool`` for a BOOL one). Typed
313+
# null/list semantics exist only under EXPR whole-field
314+
# resolution, so everything here resolves to its string form.
304315
resolved.append(value if isinstance(value, str) else str(value))
305-
elif expr_value.is_null:
316+
elif kind is _ExprKind.NULL:
306317
continue
307-
elif _is_expr_list(expr_value):
308-
resolved.extend(str(element) for element in expr_value)
309-
else:
310-
resolved.append(str(expr_value))
318+
elif kind is _ExprKind.LIST:
319+
resolved.extend(str(element) for element in value)
320+
else: # _ExprKind.SCALAR
321+
resolved.append(str(value))
311322
return resolved
312323

313324

test/openjd/sessions_v0/test_session_let_bindings.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,40 @@ def test_let_bound_list_flattens_into_args(self) -> None:
283283
# whitespace preserved — not rendered as a single stringified list.
284284
assert resolved == ["front", "alpha beta", "gamma", "back"]
285285

286+
def test_let_bound_scalar_becomes_exactly_one_argument(self) -> None:
287+
"""The counterpart to list flattening: a non-null, non-list typed value
288+
is ONE argument and must not be flattened.
289+
290+
Pins the scalar arm of the typed-argument dispatch, which list and null
291+
coverage alone leaves open. A scalar ``ExprValue`` is not iterable, so
292+
misclassifying one as a list raises TypeError out of the runner -- and
293+
with only list/null cases covered, nothing noticed.
294+
"""
295+
# GIVEN: a step script binding an int and a string with embedded
296+
# whitespace, both consumed as whole-field argument expressions.
297+
context = ModelParsingContext_2023_09(supported_extensions=["EXPR"])
298+
script = StepScript_2023_09.model_validate(
299+
{
300+
"let": ["count = 7", "label = 'solo value'"],
301+
"actions": {
302+
"onRun": {
303+
"command": "echo",
304+
"args": ["front", "{{ count }}", "{{ label }}", "back"],
305+
}
306+
},
307+
},
308+
context=context,
309+
)
310+
311+
# WHEN
312+
symtab = SymbolTable()
313+
apply_let_bindings(symtab=symtab, let_bindings=script.let or [])
314+
resolved = resolve_action_arg_values(script.actions.onRun.args, symtab)
315+
316+
# THEN: one argument each -- the int rendered, and the string kept whole
317+
# rather than split on its space or flattened character-wise.
318+
assert resolved == ["front", "7", "solo value", "back"]
319+
286320

287321
# ---------------------------------------------------------------------------
288322
# The mirror of the case above (RFC 0005 §1.3.2, the LEGACY path): a legacy

0 commit comments

Comments
 (0)