Skip to content

Commit 53b0fd1

Browse files
committed
fix: Pickle-safe deferred FormatStrings; scope parity for SimpleAction and wrap hooks
Fix three review findings on the run-time-deferred timeout/cancelation work: 1. Jobs carrying deferred FormatStrings were unpicklable and un-deepcopyable. FormatString.__new__ requires a keyword-only parsing context, but str.__getnewargs__ supplies only the value, so pickle.loads, copy.copy/deepcopy, and model_copy(deep=True) raised TypeError - and subclasses that default the context (ArgString, CommandString) either failed pickling on the engine-backed parse tree or re-parsed EXPR-grammar strings with the legacy parser. FormatString now snapshots the parse-relevant context (spec revision + extensions) at construction and implements __getnewargs_ex__ (reconstructing through __new__ with a context that reproduces the original grammar) plus __getstate__ returning None (the parse tree is rebuilt, never serialized). EXPR strings round-trip with identical typed evaluation. 2. SimpleAction validated timeout/cancelation at the ambient TASK scope while the identical desugared Action form validates them at TEMPLATE scope. Add the same _template_field_scopes to SimpleAction, so e.g. a bash-sugar timeout referencing Task.Param.* is now rejected exactly like the script-form equivalent. 3. Wrap-hook timeout/cancelation could reference WrappedEnv.Name / WrappedStep.Name because all per-hook symbols were injected at TEMPLATE scope. openjd-rs validates hook timeout/cancelation against template symbols + WrappedAction.* only. Split the injection: _template_field_inject keeps WrappedAction.* (all scopes) and the new _template_field_inject_session carries WrappedEnv.Name / WrappedStep.Name at SESSION scope, so hook command/args still accept them but timeout/cancelation reject them. 4. §3.4.2 empty-PATH propagation (flagged by the automated PR review): PathTaskParameterDefinition rejects literal empty-string range items at parse time, but RangeListTaskParameterDefinition - the model that RFC 0006 typed whole-field resolution instantiates ranges into - had no such check, so `range: "{{Param.Paths}}"` with a LIST[PATH] job parameter containing "" flowed into the instantiated Job. Add the matching validator on the create-time target model (PATH only; STRING ranges legitimately allow ""), mirroring openjd-rs's resolve-time check in create_job (ranges.rs). Also reword three stale comments claiming create-time resolution (the fields validate at template scope but resolve at run time), and hoist redundant function-local imports in the FB1 and wrap-actions tests. Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
1 parent 914ea05 commit 53b0fd1

8 files changed

Lines changed: 486 additions & 34 deletions

File tree

src/openjd/model/_format_strings/_format_string.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,18 @@
77
from .._symbol_table import SymbolTable
88
from ._dyn_constrained_str import DynamicConstrainedStr
99
from ._expression import InterpolationExpression
10-
from .._types import ModelParsingContextInterface
10+
from .._types import ModelParsingContextInterface, SpecificationRevision
11+
12+
13+
class _ReconstructionContext(ModelParsingContextInterface):
14+
"""Minimal parsing context used when a FormatString is reconstructed by
15+
pickle or the copy module (see ``FormatString.__getnewargs_ex__``).
16+
17+
It carries only the parse-relevant snapshot (specification revision and
18+
extension set) that the FormatString recorded at construction time, so
19+
the reconstructed instance is re-parsed under the same grammar (EXPR vs
20+
legacy) as the original.
21+
"""
1122

1223

1324
@dataclass
@@ -31,6 +42,13 @@ def __init__(self, *, string: str, start: int, end: int, expr: str = "", details
3142

3243
class FormatString(DynamicConstrainedStr):
3344
_processed_list: list[Union[str, ExpressionInfo]]
45+
# Parse-relevant snapshot of the construction context, recorded so that
46+
# pickle/copy reconstruction re-parses the string under the same grammar
47+
# (EXPR vs legacy). The context object itself is not retained: it is
48+
# shared and mutable (its extension set is narrowed while the template's
49+
# `extensions` field is validated), so we snapshot at construction time.
50+
_parse_spec_rev: SpecificationRevision
51+
_parse_extensions: frozenset[str]
3452

3553
def __new__(cls, value: str, *, context: ModelParsingContextInterface):
3654
"""
@@ -53,9 +71,35 @@ def __new__(cls, value: str, *, context: ModelParsingContextInterface):
5371
FormatStringError: if the original string is nonvalid.
5472
"""
5573
self = super().__new__(cls, value, context=context)
74+
self._parse_spec_rev = context.spec_rev
75+
self._parse_extensions = frozenset(context.extensions)
5676
self._processed_list = self._preprocess(context=context)
5777
return self
5878

79+
def __getnewargs_ex__(self) -> tuple[tuple[str], dict[str, Any]]:
80+
"""Support for pickling and copying (``pickle``, ``copy.copy``,
81+
``copy.deepcopy``, and pydantic's ``model_copy(deep=True)``).
82+
83+
``str.__getnewargs__`` supplies only the string value, which cannot
84+
satisfy the keyword-only ``context`` argument of ``__new__`` — and for
85+
subclasses that default the argument, it would re-parse an
86+
EXPR-grammar string with the legacy parser. Reconstruct through
87+
``__new__`` with a context carrying the recorded parse snapshot so
88+
the copy is parsed exactly as the original was.
89+
"""
90+
context = _ReconstructionContext(
91+
spec_rev=self._parse_spec_rev,
92+
supported_extensions=self._parse_extensions,
93+
)
94+
return ((str(self),), {"context": context})
95+
96+
def __getstate__(self) -> None:
97+
"""No instance state is serialized: ``_processed_list`` holds
98+
engine-backed parse trees that cannot be pickled, and ``__new__``
99+
fully rebuilds it (and the parse snapshot) from the string value and
100+
the reconstruction context (see ``__getnewargs_ex__``)."""
101+
return None
102+
59103
@property
60104
def original_value(self) -> str:
61105
"""

src/openjd/model/_internal/_variable_reference_validation.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -415,15 +415,26 @@ def _validate_model_template_variable_references(
415415
# Per-field extra symbols (e.g. the RFC 0008 WrappedAction.* variables
416416
# within their wrap hook's action). Added at TEMPLATE scope so they
417417
# are visible in every scope within the field's subtree, including
418-
# creation-time fields such as the action's timeout — a wrap hook's
419-
# timeout may forward "{{WrappedAction.Timeout}}".
418+
# the template-scoped timeout/cancelation fields — a wrap hook's
419+
# timeout may forward "{{WrappedAction.Timeout}}" for run-time
420+
# resolution.
420421
for symbol in model._template_field_inject.get(field_name, set()):
421422
symbol_name = symbol[1:] if symbol.startswith("|") else f"{symbol_prefix}{symbol}"
422423
_add_symbol(validation_symbols, ResolutionScope.TEMPLATE, symbol_name)
423424

424-
# Per-field scope override: fields that resolve at job creation (e.g.
425-
# an Action's timeout/cancelation) validate at their declared scope
426-
# rather than the model's ambient scope.
425+
# Per-field SESSION-scoped symbols (e.g. the RFC 0008
426+
# WrappedEnv.Name / WrappedStep.Name variables): visible to the
427+
# field subtree's session/task-scoped fields (a hook's command/args)
428+
# but not to its template-scoped fields (timeout/cancelation),
429+
# matching openjd-rs's wrap-hook validation.
430+
for symbol in model._template_field_inject_session.get(field_name, set()):
431+
symbol_name = symbol[1:] if symbol.startswith("|") else f"{symbol_prefix}{symbol}"
432+
_add_symbol(validation_symbols, ResolutionScope.SESSION, symbol_name)
433+
434+
# Per-field scope override: fields such as an Action's
435+
# timeout/cancelation validate at their declared (template) scope
436+
# rather than the model's ambient scope, even though their values are
437+
# carried through job creation unresolved and resolve at run time.
427438
field_scope = model._template_field_scopes.get(field_name, current_scope)
428439

429440
errors.extend(

src/openjd/model/_types.py

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -377,21 +377,31 @@ class OpenJDModel(BaseModel):
377377

378378
# Per-field variable-scope overrides: fields listed here (and their
379379
# submodels) validate their format-string references at the given scope
380-
# instead of the model's ambient scope. Used for fields that resolve at
381-
# job creation while their siblings resolve at run time — e.g. an
382-
# Action's `timeout` and `cancelation` are creation-time fields
383-
# (template scope: no Session.*, no Env.File.*/Task.File.*, no host
384-
# functions) while its `command`/`args` resolve in the session.
380+
# instead of the model's ambient scope. Used for fields that VALIDATE at
381+
# template scope (no Session.*, no Env.File.*/Task.File.*, no host
382+
# functions) while their siblings validate at the ambient session/task
383+
# scope — e.g. an Action's `timeout` and `cancelation`, which are carried
384+
# through job creation unresolved and resolve at run time, but may only
385+
# reference template-scope symbols (plus per-field injections below).
385386
_template_field_scopes: ClassVar[dict[str, ResolutionScope]] = {}
386387

387388
# Per-field extra symbol injection: symbols listed here are visible in
388389
# every scope, but only within the named field's subtree. Names use the
389390
# DefinesTemplateVariables.inject spelling (a "|" prefix discards the
390-
# parent scope prefix). Used for the RFC 0008 wrap hooks, whose
391-
# WrappedAction.* / WrappedEnv.* / WrappedStep.* variables exist only
392-
# within their hook's action.
391+
# parent scope prefix). Used for the RFC 0008 wrap hooks' WrappedAction.*
392+
# variables, which exist only within their hook's action but must be
393+
# visible even to the hook's template-scoped fields (timeout/cancelation)
394+
# for round-trip forwarding.
393395
_template_field_inject: ClassVar[dict[str, set[str]]] = {}
394396

397+
# As _template_field_inject, but the symbols are injected at SESSION
398+
# scope: visible to the field subtree's session/task-scoped fields
399+
# (e.g. a wrap hook's command/args) but NOT to its template-scoped
400+
# fields (timeout/cancelation). Used for the RFC 0008 WrappedEnv.Name /
401+
# WrappedStep.Name variables, which openjd-rs excludes from hook
402+
# timeout/cancelation validation.
403+
_template_field_inject_session: ClassVar[dict[str, set[str]]] = {}
404+
395405
# ----
396406
# Metadata used in the creation of a Job from a Job Template
397407

src/openjd/model/v2023_09/_model.py

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -683,10 +683,16 @@ class EnvironmentActions(OpenJDModel_v2023_09):
683683
# hook's action, seeded by the runtime when the hook runs in place of the
684684
# wrapped action. WrappedAction.* is available in all three hooks;
685685
# WrappedEnv.Name only in the env-enter/exit hooks; WrappedStep.Name only
686-
# in the task-run hook. Injected per-field at every scope so a hook's
687-
# creation-scoped fields (timeout/cancelation) can round-trip forward
688-
# the wrapped action's values ("{{WrappedAction.Timeout}}",
689-
# "{{WrappedAction.Cancelation.Mode}}").
686+
# in the task-run hook.
687+
#
688+
# WrappedAction.* is injected at every scope so a hook's template-scoped
689+
# fields (timeout/cancelation) can round-trip forward the wrapped
690+
# action's values ("{{WrappedAction.Timeout}}",
691+
# "{{WrappedAction.Cancelation.Mode}}"). WrappedEnv.Name /
692+
# WrappedStep.Name are injected at SESSION scope only: a hook's
693+
# command/args may reference them, but its timeout/cancelation may not —
694+
# matching openjd-rs (format_strings.rs validates hook
695+
# timeout/cancelation against template symbols + WrappedAction.* only).
690696
_WRAPPED_ACTION_SYMBOLS: ClassVar[set[str]] = {
691697
"|WrappedAction.Command",
692698
"|WrappedAction.Args",
@@ -696,9 +702,14 @@ class EnvironmentActions(OpenJDModel_v2023_09):
696702
"|WrappedAction.Cancelation.NotifyPeriodInSeconds",
697703
}
698704
_template_field_inject = {
699-
"onWrapEnvEnter": _WRAPPED_ACTION_SYMBOLS | {"|WrappedEnv.Name"},
700-
"onWrapEnvExit": _WRAPPED_ACTION_SYMBOLS | {"|WrappedEnv.Name"},
701-
"onWrapTaskRun": _WRAPPED_ACTION_SYMBOLS | {"|WrappedStep.Name"},
705+
"onWrapEnvEnter": _WRAPPED_ACTION_SYMBOLS,
706+
"onWrapEnvExit": _WRAPPED_ACTION_SYMBOLS,
707+
"onWrapTaskRun": _WRAPPED_ACTION_SYMBOLS,
708+
}
709+
_template_field_inject_session = {
710+
"onWrapEnvEnter": {"|WrappedEnv.Name"},
711+
"onWrapEnvExit": {"|WrappedEnv.Name"},
712+
"onWrapTaskRun": {"|WrappedStep.Name"},
702713
}
703714

704715
@model_validator(mode="before")
@@ -1040,6 +1051,17 @@ class SimpleAction(OpenJDModel_v2023_09):
10401051
"args": {"__self__"},
10411052
}
10421053

1054+
# Parity with Action: `timeout` and `cancelation` VALIDATE at template
1055+
# scope (no Session.*, no Env.File.*/Task.File.*, no host-context
1056+
# functions) even though the desugared SimpleAction is otherwise
1057+
# TASK-scoped. The desugared form is an ordinary Action, whose identical
1058+
# fields validate at template scope — matching openjd-rs
1059+
# (format_strings.rs validates these fields against the template symtab).
1060+
_template_field_scopes = {
1061+
"timeout": ResolutionScope.TEMPLATE,
1062+
"cancelation": ResolutionScope.TEMPLATE,
1063+
}
1064+
10431065
@field_validator("let")
10441066
@classmethod
10451067
def _validate_let(cls, v: Any, info: ValidationInfo) -> Any:
@@ -1243,6 +1265,22 @@ def _validate_range_len(cls, value: Any) -> Any:
12431265
# resolve-time checks in create_job (ranges.rs).
12441266
return validate_task_param_range_list_len(value)
12451267

1268+
@field_validator("range")
1269+
@classmethod
1270+
def _validate_no_empty_path_values(cls, value: Any, info: ValidationInfo) -> Any:
1271+
# §3.4.2: an empty string is not a valid path on any OS, so a PATH
1272+
# task parameter's range may not contain one. The template model's
1273+
# parse-time validator cannot see values that arrive via RFC 0006
1274+
# typed whole-field resolution (e.g. `range: "{{Param.Paths}}"` with
1275+
# a LIST[PATH] job parameter), so the check is enforced on the
1276+
# instantiation target as well — matching openjd-rs's resolve-time
1277+
# check in create_job (ranges.rs).
1278+
if info.data.get("type") == TaskParameterType.PATH and isinstance(value, list):
1279+
for i, item in enumerate(value):
1280+
if str(item) == "":
1281+
raise ValueError(f"range[{i}] must not be an empty string.")
1282+
return value
1283+
12461284

12471285
class RangeExpressionTaskParameterDefinition(OpenJDModel_v2023_09):
12481286
# element type of items in the range
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
"""Regression tests: Jobs carrying deferred (run-time-resolved) FormatStrings
3+
must be picklable and copyable.
4+
5+
FormatString.__new__ requires a keyword-only parsing context, but
6+
str.__getnewargs__ supplies only the string value, so pickle.loads,
7+
copy.copy/deepcopy, and model_copy(deep=True) raised TypeError — and
8+
subclasses that default the context (e.g. ArgString) silently re-parsed
9+
EXPR-grammar strings with the legacy parser (or failed pickling on the
10+
engine-backed parse tree). FormatString now records a parse-context snapshot
11+
and reconstructs through __getnewargs_ex__, re-parsing under the same grammar
12+
(see FormatString.__getstate__/__getnewargs_ex__).
13+
"""
14+
15+
import copy
16+
import pickle
17+
18+
from openjd.model import SymbolTable, create_job, decode_job_template
19+
from openjd.model._format_strings import FormatString
20+
from openjd.model._types import ParameterValue, ParameterValueType
21+
from openjd.model.v2023_09 import ArgString, ModelParsingContext
22+
23+
24+
def _job_with_deferred_fields(extensions: list[str], *, timeout: str):
25+
"""An instantiated Job whose onRun action carries a deferred (unresolved)
26+
FormatString timeout and a deferred cancelation (mode + notifyPeriod)."""
27+
template = decode_job_template(
28+
template={
29+
"specificationVersion": "jobtemplate-2023-09",
30+
"extensions": list(extensions),
31+
"name": "PickleTest",
32+
"parameterDefinitions": [
33+
{"name": "TO", "type": "INT", "default": 30},
34+
{"name": "Mode", "type": "STRING", "default": "TERMINATE"},
35+
],
36+
"steps": [
37+
{
38+
"name": "Step1",
39+
"script": {
40+
"actions": {
41+
"onRun": {
42+
"command": "echo",
43+
"timeout": timeout,
44+
"cancelation": {
45+
"mode": "{{Param.Mode}}",
46+
"notifyPeriodInSeconds": "{{Param.TO}}",
47+
},
48+
}
49+
}
50+
},
51+
}
52+
],
53+
},
54+
supported_extensions=extensions,
55+
)
56+
return create_job(
57+
job_template=template,
58+
job_parameter_values={
59+
"TO": ParameterValue(type=ParameterValueType.INT, value="30"),
60+
"Mode": ParameterValue(type=ParameterValueType.STRING, value="TERMINATE"),
61+
},
62+
)
63+
64+
65+
def _deferred_symtab() -> SymbolTable:
66+
symtab = SymbolTable()
67+
symtab["Param.TO"] = 30
68+
symtab["Param.Mode"] = "TERMINATE"
69+
return symtab
70+
71+
72+
def _assert_deferred_fields_equivalent(original, reconstructed) -> None:
73+
"""The reconstructed action's deferred FormatStrings must be FormatStrings
74+
that evaluate identically to the originals against the same symtab."""
75+
symtab = _deferred_symtab()
76+
for attr_path in ("timeout", "cancelation.mode", "cancelation.notifyPeriodInSeconds"):
77+
orig_value = original
78+
recon_value = reconstructed
79+
for part in attr_path.split("."):
80+
orig_value = getattr(orig_value, part)
81+
recon_value = getattr(recon_value, part)
82+
assert isinstance(recon_value, FormatString), attr_path
83+
assert type(recon_value) is type(orig_value), attr_path
84+
assert str(recon_value) == str(orig_value), attr_path
85+
assert recon_value.resolve(symtab=symtab) == orig_value.resolve(symtab=symtab), attr_path
86+
87+
88+
class TestJobPickleRoundTrip:
89+
def test_expr_job_pickle_round_trip(self) -> None:
90+
# GIVEN an EXPR + FEATURE_BUNDLE_1 job with deferred timeout and
91+
# cancelation, where the timeout uses EXPR-only arithmetic (the
92+
# legacy grammar cannot parse it — a reconstruction under a default
93+
# context would fail or change semantics).
94+
job = _job_with_deferred_fields(["EXPR", "FEATURE_BUNDLE_1"], timeout="{{ Param.TO * 2 }}")
95+
96+
# WHEN
97+
reconstructed = pickle.loads(pickle.dumps(job))
98+
99+
# THEN
100+
original_action = job.steps[0].script.actions.onRun
101+
recon_action = reconstructed.steps[0].script.actions.onRun
102+
_assert_deferred_fields_equivalent(original_action, recon_action)
103+
symtab = _deferred_symtab()
104+
assert recon_action.timeout.resolve(symtab=symtab) == "60"
105+
106+
def test_legacy_fb1_job_pickle_round_trip(self) -> None:
107+
# GIVEN a legacy (non-EXPR) FEATURE_BUNDLE_1 job with a deferred
108+
# FormatString timeout.
109+
job = _job_with_deferred_fields(["FEATURE_BUNDLE_1"], timeout="{{Param.TO}}")
110+
111+
# WHEN
112+
reconstructed = pickle.loads(pickle.dumps(job))
113+
114+
# THEN
115+
original_action = job.steps[0].script.actions.onRun
116+
recon_action = reconstructed.steps[0].script.actions.onRun
117+
_assert_deferred_fields_equivalent(original_action, recon_action)
118+
symtab = _deferred_symtab()
119+
assert recon_action.timeout.resolve(symtab=symtab) == "30"
120+
121+
def test_expr_job_deepcopy_and_model_copy(self) -> None:
122+
# GIVEN
123+
job = _job_with_deferred_fields(["EXPR", "FEATURE_BUNDLE_1"], timeout="{{ Param.TO * 2 }}")
124+
original_action = job.steps[0].script.actions.onRun
125+
126+
# WHEN / THEN: both deep-copy paths reconstruct working FormatStrings.
127+
for job_copy in (copy.deepcopy(job), job.model_copy(deep=True)):
128+
copied_action = job_copy.steps[0].script.actions.onRun
129+
_assert_deferred_fields_equivalent(original_action, copied_action)
130+
131+
132+
class TestFormatStringPickleFaithfulness:
133+
"""The reconstruction context must reproduce the original parse mode:
134+
re-parsing an EXPR-grammar string under a default (legacy) context would
135+
reject it outright or silently change its evaluation semantics."""
136+
137+
def test_expr_arg_string_typed_round_trip(self) -> None:
138+
# GIVEN an ArgString whose whole-field EXPR list expression only
139+
# parses under the EXPR grammar.
140+
context = ModelParsingContext(supported_extensions=["EXPR", "FEATURE_BUNDLE_1"])
141+
original = ArgString('{{ ["a","b c"] }}', context=context)
142+
143+
# WHEN
144+
reconstructed = pickle.loads(pickle.dumps(original))
145+
146+
# THEN: still an EXPR whole-field expression with identical typed
147+
# (RFC 0005) evaluation, not a legacy re-parse.
148+
assert type(reconstructed) is ArgString
149+
assert reconstructed.whole_field_expression() is not None
150+
symtab = SymbolTable()
151+
original_value = original.resolve_value(symtab=symtab)
152+
reconstructed_value = reconstructed.resolve_value(symtab=symtab)
153+
assert type(reconstructed_value) is type(original_value)
154+
assert str(reconstructed_value) == str(original_value)
155+
assert reconstructed.resolve(symtab=symtab) == original.resolve(symtab=symtab)
156+
157+
def test_legacy_format_string_copy(self) -> None:
158+
# A plain legacy-grammar FormatString survives copy.copy and deepcopy.
159+
context = ModelParsingContext()
160+
original = FormatString("prefix {{ Task.Param.Frame }} suffix", context=context)
161+
symtab = SymbolTable()
162+
symtab["Task.Param.Frame"] = 7
163+
for reconstructed in (copy.copy(original), copy.deepcopy(original)):
164+
assert str(reconstructed) == str(original)
165+
assert reconstructed.resolve(symtab=symtab) == "prefix 7 suffix"

0 commit comments

Comments
 (0)