Skip to content

Commit a214633

Browse files
committed
fix: RFC 0005/0008 typed-arg and null-vs-empty parity with openjd-rs
Two Rust-parity fixes in the v0 session runtime: 1. WrappedAction.Args now uses RFC 0005 1.3.2 typed argument semantics. The enforcement path's typed arg loop (null skip, list flattening, display coercion) is extracted into a shared module-level helper, resolve_action_arg_values, in _runner_base.py; both _inject_wrapped_task_symbols and _inject_wrapped_env_symbols now use it, so a wrap hook sees exactly the argv the wrapped action would have run with unwrapped -- mirroring openjd-rs, whose seed_wrapped_action_symbols resolves through the same resolve_action_args as the runner. The helper also gains openjd-rs's plain-string-resolution fallback when typed resolution fails. 2. An empty string is no longer conflated with null. resolve_optional_int_field and resolve_effective_cancelation's deferred-mode branch now resolve typed (resolve_value) and treat only a typed null result as "field omitted" / "no cancelation declared". A genuine empty string now reaches the "must be a positive integer, got ''" / "must resolve to ... got ''" errors, matching openjd-rs's resolve_action_timeout, resolve_notify_period_seconds, and resolve_effective_cancelation, which only special-case ExprValue::Null. The whole_field_expression() pre-check is removed: resolve_value only yields a typed null for whole-field expressions, so null semantics remain whole-field-only by construction. Test updates: the deferred-cancelation unit-test helper now parses its format strings with the EXPR extension (deferred forwarding is an RFC 0008 construct and WRAP_ACTIONS requires EXPR), since the previous legacy parse pinned the non-Rust "" == null behavior. Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
1 parent 4fc6edd commit a214633

5 files changed

Lines changed: 159 additions & 47 deletions

File tree

src/openjd/sessions/_runner_base.py

Lines changed: 81 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"NotifyCancelMethod",
3737
"ScriptRunnerBase",
3838
"apply_let_bindings",
39+
"resolve_action_arg_values",
3940
"resolve_effective_cancelation",
4041
"resolve_optional_int_field",
4142
)
@@ -96,6 +97,48 @@ class NotifyCancelMethod(CancelMethod):
9697
"""Amount of time after a SIGTERM to wait to do the SIGKILL"""
9798

9899

100+
def resolve_action_arg_values(args: Optional[Sequence], symtab: SymbolTable) -> list[str]:
101+
"""Resolve an action's ``args`` field into its flat list of argument
102+
strings (excluding the command).
103+
104+
RFC 0005 §1.3.2 argument semantics, mirroring openjd-rs's
105+
resolve_action_args: a whole-field expression argument resolves typed —
106+
a null result skips the argument, a list result flattens inline (one
107+
argument per element, rendered with the engine's display coercion), and
108+
a scalar becomes a single argument. Multi-segment format strings and
109+
legacy (non-EXPR) expressions resolve to their string form.
110+
111+
Shared by the enforcement path (:meth:`ScriptRunnerBase._run_action`)
112+
and the RFC 0008 ``WrappedAction.Args`` injection, so a wrap hook sees
113+
exactly the arguments the wrapped action would have run with unwrapped.
114+
115+
Raises:
116+
FormatStringError: If an argument's expression cannot be resolved.
117+
"""
118+
resolved: list[str] = []
119+
if args is not None:
120+
for arg in args:
121+
try:
122+
value = arg.resolve_value(symtab=symtab)
123+
except FormatStringError:
124+
# Mirror openjd-rs's resolve_action_args: when typed
125+
# resolution fails (e.g. a legacy-parsed expression meeting
126+
# a typed symbol value), fall back to plain string
127+
# resolution — which raises FormatStringError itself if the
128+
# argument is genuinely unresolvable.
129+
resolved.append(arg.resolve(symtab=symtab))
130+
continue
131+
if isinstance(value, str):
132+
resolved.append(value)
133+
elif getattr(value, "is_null", False):
134+
continue
135+
elif str(getattr(value, "type", "")).startswith("list["):
136+
resolved.extend(str(element) for element in value)
137+
else:
138+
resolved.append(str(value))
139+
return resolved
140+
141+
99142
def resolve_optional_int_field(
100143
value: Any,
101144
symtab: SymbolTable,
@@ -111,13 +154,16 @@ def resolve_optional_int_field(
111154
- ``None`` (field omitted) stays ``None``.
112155
- A literal ``int`` passes through unchecked: literal values were
113156
bounds-checked by the static validator at parse time.
114-
- A FormatString (FEATURE_BUNDLE_1) is resolved against ``symtab``. A
115-
whole-field expression that resolves to null renders as the empty
116-
string and is treated as if the field were not provided (``None`` —
117-
the caller applies any positional schema default). Otherwise the
118-
resolved value must be an integer within the given bounds; the bounds
119-
apply here because format-string values could not be checked at parse
120-
time.
157+
- A FormatString (FEATURE_BUNDLE_1) is resolved against ``symtab``
158+
using typed resolution. A whole-field expression that resolves to a
159+
typed null is treated as if the field were not provided (``None`` —
160+
the caller applies any positional schema default). Any other result
161+
— including a genuine empty string — must be an integer within the
162+
given bounds; the bounds apply here because format-string values
163+
could not be checked at parse time. This matches the openjd-rs
164+
runtime (resolve_action_timeout / resolve_notify_period_seconds),
165+
which only treats an ExprValue::Null result as "field omitted" and
166+
errors on an empty string.
121167
122168
Raises:
123169
ValueError: If the resolved value is not an integer, or violates
@@ -128,9 +174,15 @@ def resolve_optional_int_field(
128174
return None
129175
if isinstance(value, int):
130176
return value
131-
resolved = value.resolve(symtab=symtab)
132-
if resolved == "":
177+
# Typed resolution: a whole-field EXPR expression yields the engine's
178+
# typed value, so a null result is distinguishable from a genuine empty
179+
# string. Multi-segment and legacy (non-EXPR) format strings fall back
180+
# to plain string resolution — correct, since typed nulls only exist
181+
# under EXPR whole-field semantics (Template Schemas 5.3).
182+
resolved_value = value.resolve_value(symtab=symtab)
183+
if getattr(resolved_value, "is_null", False):
133184
return None
185+
resolved = str(resolved_value)
134186
if ge is not None and le is not None:
135187
constraint = f"between {ge} and {le}"
136188
elif ge == 1 and le is None:
@@ -195,19 +247,23 @@ def resolve_period(period: Any) -> Optional[int]:
195247
if cancelation is None:
196248
return (None, None)
197249
if isinstance(cancelation, CancelationMethodDeferred_2023_09):
198-
# Null semantics apply only to a whole-field expression
199-
# ("{{ ... }}" with no surrounding text, target type string? —
200-
# Template Schemas 5.3). A normal format string that happens to
201-
# resolve to the empty string is NOT null; it falls through to the
202-
# "must resolve to..." error below.
203-
is_whole_field = cancelation.mode.whole_field_expression() is not None
204-
mode = cancelation.mode.resolve(symtab=symtab)
205-
if mode == "" and is_whole_field:
250+
# Typed resolution. Null semantics apply only to a whole-field
251+
# expression ("{{ ... }}" with no surrounding text, target type
252+
# string? — Template Schemas 5.3), and resolve_value only yields a
253+
# typed null for a whole-field EXPR expression; every other format
254+
# string resolves to its plain string form. A format string that
255+
# happens to resolve to the empty string is NOT null; it falls
256+
# through to the "must resolve to..." error below (matching the
257+
# openjd-rs runtime, which errors on any non-null, non-mode-name
258+
# result).
259+
mode_value = cancelation.mode.resolve_value(symtab=symtab)
260+
if getattr(mode_value, "is_null", False):
206261
# Null mode drops the ENTIRE cancelation object: mode is the
207262
# object's required discriminator, so an "omitted" mode cannot
208263
# leave a partial object behind. The action behaves exactly as
209264
# if no <Cancelation> were declared.
210265
return (None, None)
266+
mode = str(mode_value)
211267
if mode == CancelationMode_2023_09.TERMINATE.value:
212268
# Post-resolution the object must validate against the resolved
213269
# variant's shape: TERMINATE admits no notify period.
@@ -644,24 +700,10 @@ def _run_action(
644700
assert isinstance(action, Action_2023_09)
645701
try:
646702
command = [action.command.resolve(symtab=symtab)]
647-
if action.args is not None:
648-
# RFC 0005 §1.3.2 argument semantics, mirroring openjd-rs's
649-
# resolve_action_args: a whole-field expression argument
650-
# resolves typed — a null result skips the argument, a list
651-
# result flattens inline (one argument per element, rendered
652-
# with the engine's display coercion), and a scalar becomes a
653-
# single argument. Multi-segment format strings and legacy
654-
# (non-EXPR) expressions resolve to their string form.
655-
for arg in action.args:
656-
value = arg.resolve_value(symtab=symtab)
657-
if isinstance(value, str):
658-
command.append(value)
659-
elif getattr(value, "is_null", False):
660-
continue
661-
elif str(getattr(value, "type", "")).startswith("list["):
662-
command.extend(str(element) for element in value)
663-
else:
664-
command.append(str(value))
703+
# RFC 0005 §1.3.2 typed argument semantics (null skip, list
704+
# flattening) — see resolve_action_arg_values, shared with the
705+
# RFC 0008 WrappedAction.Args injection.
706+
command.extend(resolve_action_arg_values(action.args, symtab))
665707
except FormatStringError as exc:
666708
# Extremely unlikely since a JobTemplate needs to have passed
667709
# validation before we could be running it, but just to be safe.
@@ -670,11 +712,10 @@ def _run_action(
670712
time_limit: Optional[timedelta] = default_timeout
671713
# A FormatString timeout (FEATURE_BUNDLE_1) is resolved right
672714
# before the action runs. A whole-field expression that
673-
# resolves to null renders as the empty string — e.g.
674-
# forwarding `timeout: "{{WrappedAction.Timeout}}"`
675-
# (RFC 0008) when the wrapped action specified no
676-
# timeout — and is treated as if the field were not
677-
# provided, so the positional default applies.
715+
# resolves to a typed null — e.g. forwarding
716+
# `timeout: "{{WrappedAction.Timeout}}"` (RFC 0008) when the
717+
# wrapped action specified no timeout — is treated as if the
718+
# field were not provided, so the positional default applies.
678719
try:
679720
seconds = resolve_optional_int_field(
680721
action.timeout, symtab, ge=1, description="timeout"

src/openjd/sessions/_session.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
from ._runner_base import (
4646
ScriptRunnerBase,
4747
apply_let_bindings,
48+
resolve_action_arg_values,
4849
resolve_effective_cancelation,
4950
resolve_optional_int_field,
5051
)
@@ -1771,9 +1772,13 @@ def _inject_wrapped_env_symbols(
17711772
is removed from tracking, so the wrapped environment's own
17721773
variables are included."""
17731774
command = inner_action.command.resolve(symtab=inner_symtab)
1774-
args = (
1775-
[a.resolve(symtab=inner_symtab) for a in inner_action.args] if inner_action.args else []
1776-
)
1775+
# RFC 0005 §1.3.2 typed argument semantics (null skip, list
1776+
# flattening), shared with the enforcement path
1777+
# (ScriptRunnerBase._run_action) so the hook sees exactly the
1778+
# arguments the wrapped action would have run with unwrapped —
1779+
# mirroring openjd-rs's seed_wrapped_action_symbols, which resolves
1780+
# via the same resolve_action_args as the runner.
1781+
args = resolve_action_arg_values(inner_action.args, inner_symtab)
17771782
symtab["WrappedAction.Command"] = command
17781783
symtab["WrappedAction.Args"] = args
17791784
symtab["WrappedAction.Environment"] = (
@@ -1807,9 +1812,10 @@ def _inject_wrapped_task_symbols(
18071812
on_run = step_script.actions.onRun
18081813

18091814
symtab["WrappedAction.Command"] = on_run.command.resolve(symtab=inner_symtab)
1810-
symtab["WrappedAction.Args"] = (
1811-
[arg.resolve(symtab=inner_symtab) for arg in on_run.args] if on_run.args else []
1812-
)
1815+
# RFC 0005 §1.3.2 typed argument semantics (null skip, list
1816+
# flattening), shared with the enforcement path — see
1817+
# _inject_wrapped_env_symbols.
1818+
symtab["WrappedAction.Args"] = resolve_action_arg_values(on_run.args, inner_symtab)
18131819
symtab["WrappedAction.Environment"] = self._collect_session_env_list()
18141820
symtab["WrappedAction.Timeout"] = self._resolve_action_timeout(on_run, inner_symtab)
18151821
self._inject_wrapped_cancelation_symbols(

test/openjd/sessions_v0/test_session_let_bindings.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,16 @@ def test_whole_field_null_resolves_to_none(self) -> None:
228228
symtab["X"] = None
229229
assert resolve_optional_int_field(field, symtab, ge=1, description="timeout") is None
230230

231+
def test_whole_field_empty_string_rejected(self) -> None:
232+
# A genuine empty STRING is not null (openjd-rs parity: only an
233+
# ExprValue::Null result means "field omitted"; an empty string
234+
# falls through to the integer parse and errors).
235+
field = _format_string_field("{{ X }}")
236+
symtab = SymbolTable()
237+
symtab["X"] = ""
238+
with pytest.raises(ValueError, match="timeout must be a positive integer, got ''"):
239+
resolve_optional_int_field(field, symtab, ge=1, description="timeout")
240+
231241
def test_resolved_value_below_ge_rejected(self) -> None:
232242
field = _format_string_field("{{ X }}")
233243
symtab = SymbolTable()

test/openjd/sessions_v0/test_wrap_cancelation.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,12 @@ def _deferred(self, mode: str, period: str | None = None):
269269
from openjd.model._format_strings import FormatString
270270
from openjd.model.v2023_09 import CancelationMethodDeferred, ModelParsingContext
271271

272-
ctx = ModelParsingContext()
272+
# A deferred mode is an RFC 0008 forwarding construct, and
273+
# WRAP_ACTIONS requires the EXPR extension — so the format strings
274+
# here parse as EXPR expressions, giving the typed null semantics
275+
# the runtime relies on (a whole-field null drops the cancelation
276+
# object; an empty STRING is an error, matching openjd-rs).
277+
ctx = ModelParsingContext(supported_extensions=["FEATURE_BUNDLE_1", "EXPR"])
273278
return CancelationMethodDeferred(
274279
mode=FormatString(mode, context=ctx),
275280
notifyPeriodInSeconds=(
@@ -313,6 +318,17 @@ def test_mode_resolving_notify_then_terminate_with_period(self) -> None:
313318
)
314319
assert result == ("NOTIFY_THEN_TERMINATE", 45)
315320

321+
def test_whole_field_mode_resolving_empty_string_raises(self) -> None:
322+
# A genuine empty STRING is not null, even for a whole-field
323+
# expression (openjd-rs parity: only an ExprValue::Null result
324+
# drops the cancelation object; an empty string is an invalid
325+
# mode). E.g. a STRING parameter whose value is "".
326+
from openjd.sessions._runner_base import resolve_effective_cancelation
327+
328+
cancelation = self._deferred("{{X}}")
329+
with pytest.raises(ValueError, match="must resolve to .* got ''"):
330+
resolve_effective_cancelation(cancelation, self._symtab(X=""))
331+
316332
def test_mode_resolving_garbage_raises(self) -> None:
317333
from openjd.sessions._runner_base import resolve_effective_cancelation
318334

test/openjd/sessions_v0/test_wrap_task_run.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,45 @@ def test_injects_empty_args_when_step_has_no_args(self) -> None:
105105
finally:
106106
session.cleanup()
107107

108+
def test_injects_typed_args_null_skip_and_list_flatten(self) -> None:
109+
# RFC 0005 §1.3.2 typed argument semantics (openjd-rs parity: the
110+
# wrapped path in seed_wrapped_action_symbols resolves through the
111+
# same resolve_action_args as the runner): a whole-field list
112+
# expression flattens inline (one argument per element), a
113+
# whole-field null is skipped, and the hook sees exactly the argv
114+
# the wrapped action would have run with unwrapped.
115+
from openjd.model.v2023_09 import ModelParsingContext
116+
from openjd.sessions._runner_base import resolve_action_arg_values
117+
118+
context = ModelParsingContext(supported_extensions=["EXPR"])
119+
script = StepScript_2023_09.model_validate(
120+
{
121+
"actions": {
122+
"onRun": {
123+
"command": "echo",
124+
"args": ["front", '{{ ["a", "b c"] }}', "{{ null }}", "back"],
125+
}
126+
}
127+
},
128+
context=context,
129+
)
130+
session = Session(session_id=uuid.uuid4().hex, job_parameter_values={})
131+
try:
132+
symtab = SymbolTable()
133+
session._inject_wrapped_task_symbols(symtab, script, "MyStep", inner_symtab=symtab)
134+
assert symtab["WrappedAction.Args"] == ["front", "a", "b c", "back"]
135+
# The unwrapped enforcement path (_run_action) resolves the same
136+
# action's args via the same shared helper — wrapped and
137+
# unwrapped runs of this action use identical argv.
138+
assert resolve_action_arg_values(script.actions.onRun.args, symtab) == [
139+
"front",
140+
"a",
141+
"b c",
142+
"back",
143+
]
144+
finally:
145+
session.cleanup()
146+
108147
def test_injects_wrapped_environment_as_key_value_list(self) -> None:
109148
# RFC 0008 (openjd-rs #277): WrappedAction.Environment carries every
110149
# session-defined variable — openjd_env definitions (applied via

0 commit comments

Comments
 (0)