Skip to content

Commit ddaf000

Browse files
committed
feat: Defer format-string cancelation mode resolution to run time
Add CancelationMethodDeferred so a cancelation `mode` can be a whole-field format string, resolved at run time (Template Schemas 5.3, FEATURE_BUNDLE_1) — enabling RFC 0008 round-trip forwarding: cancelation: mode: "{{WrappedAction.Cancelation.Mode}}" notifyPeriodInSeconds: "{{WrappedAction.Cancelation.NotifyPeriodInSeconds}}" The problem: format strings are normally delay-processed, but `mode` is the schema selector — pydantic's discriminated union needs it at parse time to know which object shape it is reading, while a forwarded value only exists at run time. The fix replaces the literal-tag discriminator with a callable one that routes a format-string mode to the new deferred class, moving the TERMINATE-vs-NOTIFY_THEN_TERMINATE decision to resolution time in the sessions runtime. See the CancelationMethodDeferred docstring for the full explanation. - Callable Discriminator/Tag union (CancelationMethod) used by both Action and SimpleAction; a mode containing "{{" routes to CancelationMethodDeferred. - CancelationMethodDeferred validates that a format-string mode is gated on FEATURE_BUNDLE_1 and is a literal or a single whole-field "{{ ... }}" expression (the schema selector cannot be partially interpolated), and shares the notifyPeriodInSeconds validation with CancelationMethodNotifyThenTerminate via a hoisted helper. - Export CancelationMethodDeferred from openjd.model.v2023_09. Runtime resolution (null mode drops the whole cancelation object; null period falls back to the positional schema default) lands in openjd-sessions-for-python. Mirrors the Rust implementation in OpenJobDescription/openjd-rs#261. Tests: round-trip decode tests for the full forward and the period-only forward, plus invalid tests pinning the FEATURE_BUNDLE_1 gating and the whole-field-only rule. Full suite passes (5337 tests); all 58 WRAP_ACTIONS conformance fixtures pass end-to-end with the sessions runtime. Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
1 parent b255be6 commit ddaf000

3 files changed

Lines changed: 277 additions & 26 deletions

File tree

src/openjd/model/v2023_09/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
AttributeCapabilityValue,
1919
AttributeRequirement,
2020
AttributeRequirementTemplate,
21+
CancelationMethodDeferred,
2122
CancelationMethodNotifyThenTerminate,
2223
CancelationMethodTerminate,
2324
CancelationMode,
@@ -113,6 +114,7 @@
113114
"AttributeCapabilityValue",
114115
"AttributeRequirement",
115116
"AttributeRequirementTemplate",
117+
"CancelationMethodDeferred",
116118
"CancelationMethodNotifyThenTerminate",
117119
"CancelationMethodTerminate",
118120
"CancelationMode",

src/openjd/model/v2023_09/_model.py

Lines changed: 169 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,14 @@
1515
field_validator,
1616
model_validator,
1717
ConfigDict,
18+
Discriminator,
1819
StringConstraints,
1920
Field,
2021
PositiveInt,
2122
PositiveFloat,
2223
StrictBool,
2324
StrictInt,
25+
Tag,
2426
ValidationError,
2527
ValidationInfo,
2628
)
@@ -283,6 +285,31 @@ class CancelationMode(str, Enum):
283285
NotifyPeriodType = Annotated[int, Field(ge=1, le=600)]
284286

285287

288+
def _validate_notify_period_value(
289+
v: Any, info: ValidationInfo
290+
) -> Optional[Union[int, FormatString]]:
291+
"""Shared notifyPeriodInSeconds validation for
292+
CancelationMethodNotifyThenTerminate and CancelationMethodDeferred."""
293+
if v is None:
294+
return v
295+
context = cast(Optional[ModelParsingContext], info.context)
296+
if isinstance(v, str):
297+
if context and "FEATURE_BUNDLE_1" not in context.extensions:
298+
# Try to parse as int, fail if not
299+
try:
300+
return int(v)
301+
except ValueError:
302+
raise ValueError(
303+
"notifyPeriodInSeconds as a format string requires the FEATURE_BUNDLE_1 extension."
304+
)
305+
return validate_int_fmtstring_field(v, ge=1, context=context)
306+
if isinstance(v, int):
307+
if v < 1 or v > 600:
308+
raise ValueError("notifyPeriodInSeconds must be between 1 and 600")
309+
return v
310+
return v
311+
312+
286313
class CancelationMethodNotifyThenTerminate(OpenJDModel_v2023_09):
287314
"""Notify-then-terminate cancelation mode for an Action.
288315
@@ -323,24 +350,7 @@ class CancelationMethodNotifyThenTerminate(OpenJDModel_v2023_09):
323350
def _validate_notify_period(
324351
cls, v: Any, info: ValidationInfo
325352
) -> Optional[Union[int, FormatString]]:
326-
if v is None:
327-
return v
328-
context = cast(Optional[ModelParsingContext], info.context)
329-
if isinstance(v, str):
330-
if context and "FEATURE_BUNDLE_1" not in context.extensions:
331-
# Try to parse as int, fail if not
332-
try:
333-
return int(v)
334-
except ValueError:
335-
raise ValueError(
336-
"notifyPeriodInSeconds as a format string requires the FEATURE_BUNDLE_1 extension."
337-
)
338-
return validate_int_fmtstring_field(v, ge=1, context=context)
339-
if isinstance(v, int):
340-
if v < 1 or v > 600:
341-
raise ValueError("notifyPeriodInSeconds must be between 1 and 600")
342-
return v
343-
return v
353+
return _validate_notify_period_value(v, info)
344354

345355

346356
class CancelationMethodTerminate(OpenJDModel_v2023_09):
@@ -357,6 +367,140 @@ class CancelationMethodTerminate(OpenJDModel_v2023_09):
357367
mode: Literal[CancelationMode.TERMINATE]
358368

359369

370+
class CancelationMethodDeferred(OpenJDModel_v2023_09):
371+
"""A cancelation whose ``mode`` is a whole-field format string, resolved
372+
at run time (Template Schemas 5.3, FEATURE_BUNDLE_1 extension).
373+
374+
What is the problem this solves?
375+
376+
Format strings in general are *already* delay-processed: when a template
377+
says ``args: ["{{WrappedAction.Command}}"]``, the parser just stores
378+
"this is a format string" and the value gets resolved much later, inside
379+
a running session, right before the action launches — that's when the
380+
runtime seeds the ``WrappedAction.*`` variables from the action being
381+
wrapped. "Resolve later" is the normal pipeline for every other field.
382+
383+
``mode`` is different because it isn't a normal value field — it's the
384+
*schema selector*. The parser needs to know TERMINATE vs
385+
NOTIFY_THEN_TERMINATE at parse time to decide what shape of object it's
386+
even reading (only one of them allows ``notifyPeriodInSeconds``). So the
387+
"which shape?" decision happens at parse time, but a forwarded value
388+
like ``mode: "{{WrappedAction.Cancelation.Mode}}"`` only exists at run
389+
time — that mismatch made round-trip cancelation forwarding in RFC 0008
390+
wrap hooks impossible (pydantic's discriminated union rejected the
391+
template with "does not match any of the expected tags").
392+
393+
The fix is this class: the parser accepts a whole-field ``{{...}}``
394+
expression in ``mode`` as a third, "decided later" state (gated on the
395+
FEATURE_BUNDLE_1 extension), and the shape decision moves to resolution
396+
time, right before the action runs:
397+
398+
1. The runtime seeds ``WrappedAction.Cancelation.Mode`` from the
399+
wrapped action (``"TERMINATE"``, ``"NOTIFY_THEN_TERMINATE"``, or
400+
``None``).
401+
2. It resolves the ``mode:`` expression against that.
402+
3. ``"TERMINATE"``/``"NOTIFY_THEN_TERMINATE"`` — the cancelation block
403+
now acts as that method, and its sibling fields are validated
404+
against that shape. ``None`` (null) — the whole ``cancelation:``
405+
block is treated as never written. Anything else — the action fails.
406+
407+
Static validation is *not* deferred: at parse time the validator still
408+
checks the expression is well-formed, that ``WrappedAction.*`` is only
409+
referenced inside wrap hooks, and that the value is a single
410+
whole-field expression. You just can't know *which* of the two modes
411+
it'll be until the wrapped action is in front of you — which is
412+
inherent to forwarding: the same wrap environment gets reused across
413+
many steps whose cancelation settings differ.
414+
415+
Mirrors ``CancelationMode::DeferredMode`` in openjd-rs. See
416+
openjd-specifications Template Schemas 5.3 and RFC 0008 "Cancelation
417+
behavior".
418+
419+
Attributes:
420+
mode (FormatString): A single whole-field interpolation expression
421+
resolving to "TERMINATE", "NOTIFY_THEN_TERMINATE", or null.
422+
notifyPeriodInSeconds (Optional[Union[int, FormatString]]): As on
423+
CancelationMethodNotifyThenTerminate; only meaningful when the
424+
mode resolves to NOTIFY_THEN_TERMINATE, and must resolve to
425+
null when the mode resolves to TERMINATE.
426+
"""
427+
428+
mode: FormatString
429+
notifyPeriodInSeconds: Optional[Union[NotifyPeriodType, FormatString]] = None # noqa: N815
430+
431+
_job_creation_metadata = JobCreationMetadata(resolve_fields={"notifyPeriodInSeconds"})
432+
433+
@field_validator("mode", mode="before")
434+
@classmethod
435+
def _validate_mode(cls, v: Any, info: ValidationInfo) -> Any:
436+
if isinstance(v, str):
437+
context = cast(Optional[ModelParsingContext], info.context)
438+
if context and "FEATURE_BUNDLE_1" not in context.extensions:
439+
raise ValueError(
440+
"a format string in cancelation mode requires the FEATURE_BUNDLE_1 extension."
441+
)
442+
raw = v.strip()
443+
# The mode is the schema selector, so partial interpolation
444+
# like "TERMIN{{X}}" has no meaningful semantics: require a
445+
# literal or a single whole-field expression. Exactly one "{{"
446+
# AND exactly one "}}", anchored at both ends — counting only
447+
# "{{" would accept partial interpolations like "{{X}}Y}}"
448+
# (one open, two closes), which can never resolve to a valid
449+
# mode.
450+
if not (
451+
raw.startswith("{{")
452+
and raw.endswith("}}")
453+
and raw.count("{{") == 1
454+
and raw.count("}}") == 1
455+
):
456+
raise ValueError(
457+
"cancelation mode must be a literal or a single "
458+
'whole-field "{{ ... }}" expression.'
459+
)
460+
return v
461+
462+
@field_validator("notifyPeriodInSeconds", mode="before")
463+
@classmethod
464+
def _validate_notify_period(
465+
cls, v: Any, info: ValidationInfo
466+
) -> Optional[Union[int, FormatString]]:
467+
return _validate_notify_period_value(v, info)
468+
469+
470+
def _cancelation_discriminator(v: Any) -> Optional[str]:
471+
"""Callable discriminator for the cancelation union: routes the two
472+
literal modes to their fixed-shape classes and a format-string mode to
473+
:class:`CancelationMethodDeferred` (see that class's docstring for why
474+
the mode decision can be deferred at all)."""
475+
mode = v.get("mode") if isinstance(v, dict) else getattr(v, "mode", None)
476+
if isinstance(mode, CancelationMode):
477+
mode = mode.value
478+
if isinstance(mode, str):
479+
if mode == CancelationMode.NOTIFY_THEN_TERMINATE.value:
480+
return "notify_then_terminate"
481+
if mode == CancelationMode.TERMINATE.value:
482+
return "terminate"
483+
if "{{" in mode:
484+
return "deferred"
485+
if isinstance(v, CancelationMethodNotifyThenTerminate):
486+
return "notify_then_terminate"
487+
if isinstance(v, CancelationMethodTerminate):
488+
return "terminate"
489+
if isinstance(v, CancelationMethodDeferred):
490+
return "deferred"
491+
return None
492+
493+
494+
CancelationMethod = Annotated[
495+
Union[
496+
Annotated[CancelationMethodNotifyThenTerminate, Tag("notify_then_terminate")],
497+
Annotated[CancelationMethodTerminate, Tag("terminate")],
498+
Annotated[CancelationMethodDeferred, Tag("deferred")],
499+
],
500+
Discriminator(_cancelation_discriminator),
501+
]
502+
503+
360504
ArgListType = Annotated[list[ArgString], Field(min_length=1)]
361505

362506
# WRAP_ACTIONS (RFC 0008) wrap-hook field names on EnvironmentActions.
@@ -436,17 +580,18 @@ class Action(OpenJDModel_v2023_09):
436580
timeout (Optional[int]): Maximum allowed runtime of the Action in seconds.
437581
Can be a format string with FEATURE_BUNDLE_1 extension.
438582
Default: No timeout
439-
cancelation (Optional[Union[CancelationMethodNotifyThenTerminate, CancelationMethodTerminate]]):
440-
If defined, provides details regarding how this action should be canceled.
583+
cancelation (Optional[CancelationMethod]): If defined, provides details
584+
regarding how this action should be canceled. One of
585+
CancelationMethodNotifyThenTerminate, CancelationMethodTerminate, or
586+
CancelationMethodDeferred (a whole-field format-string mode resolved
587+
at run time; FEATURE_BUNDLE_1).
441588
Default: CancelationMethodTerminate
442589
"""
443590

444591
command: CommandString
445592
args: Optional[ArgListType] = None
446593
timeout: Optional[Union[PositiveInt, FormatString]] = None
447-
cancelation: Optional[
448-
Union[CancelationMethodNotifyThenTerminate, CancelationMethodTerminate]
449-
] = Field(None, discriminator="mode")
594+
cancelation: Optional[CancelationMethod] = None
450595

451596
_job_creation_metadata = JobCreationMetadata(resolve_fields={"timeout"})
452597

@@ -786,9 +931,7 @@ class SimpleAction(OpenJDModel_v2023_09):
786931
script: DataString
787932
args: Optional[ArgListType] = None
788933
timeout: Optional[Union[PositiveInt, FormatString]] = None
789-
cancelation: Optional[
790-
Union[CancelationMethodNotifyThenTerminate, CancelationMethodTerminate]
791-
] = Field(None, discriminator="mode")
934+
cancelation: Optional[CancelationMethod] = None
792935
let: Optional[list[str]] = None
793936

794937
# SimpleAction is syntax sugar that resolves to a StepScript (TASK scope),

test/openjd/model_v0/v2023_09/test_wrap_actions.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,3 +294,109 @@ def test_create_job_with_step_env_wrap_succeeds(self):
294294
job = create_job(job_template=jt, job_parameter_values={})
295295
step_env_actions = job.steps[0].stepEnvironments[0].script.actions
296296
assert step_env_actions.onWrapTaskRun is not None
297+
298+
299+
class TestCancelationRoundTripForwarding:
300+
"""Cancelation round-trip forwarding (openjd-rs PR #261 review,
301+
discussion r3597453516). A wrap hook must be able to forward the
302+
wrapped action's cancelation verbatim via whole-field expressions::
303+
304+
cancelation:
305+
mode: "{{WrappedAction.Cancelation.Mode}}"
306+
notifyPeriodInSeconds: "{{WrappedAction.Cancelation.NotifyPeriodInSeconds}}"
307+
308+
Under RFC 0005 type-forwarding, a single outer ``{{...}}`` forwards
309+
the expression's type into the field: a string mode behaves like the
310+
literal, and a null result means the field is omitted (a null mode
311+
drops the whole cancelation object; a null period falls back to the
312+
schema default).
313+
314+
Mirrors ``cancelation_round_trip_*`` in openjd-rs
315+
``crates/openjd-model/tests/integration/test_wrap_actions.rs``.
316+
317+
Note: the review example also forwards ``timeout:``; that is
318+
deliberately out of scope pending the WrappedAction.Timeout
319+
int-vs-int? question (the 0-when-unset sentinel is not a valid
320+
``<posinteger>``).
321+
"""
322+
323+
_ROUND_TRIP_EXTS = ["WRAP_ACTIONS", "EXPR", "FEATURE_BUNDLE_1"]
324+
325+
def _wrap_actions(self, cancelation: dict) -> dict:
326+
actions = {
327+
"onWrapEnvEnter": _cmd("{{WrappedAction.Command}}"),
328+
"onWrapTaskRun": {
329+
"command": "echo",
330+
"args": ["{{WrappedAction.Command}}"],
331+
"cancelation": cancelation,
332+
},
333+
"onWrapEnvExit": _cmd("{{WrappedAction.Command}}"),
334+
}
335+
return actions
336+
337+
def test_full_cancelation_forwarding_accepted(self):
338+
# Mark's example from the PR #261 review thread (minus timeout):
339+
# the whole-field expressions in the wrap hook's cancelation
340+
# block must parse and validate.
341+
tmpl = _env_template(
342+
self._wrap_actions(
343+
{
344+
"mode": "{{WrappedAction.Cancelation.Mode}}",
345+
"notifyPeriodInSeconds": (
346+
"{{WrappedAction.Cancelation.NotifyPeriodInSeconds}}"
347+
),
348+
}
349+
),
350+
extensions=self._ROUND_TRIP_EXTS,
351+
)
352+
_decode(tmpl, extensions=self._ROUND_TRIP_EXTS)
353+
354+
def test_notify_period_only_forwarding_accepted(self):
355+
# The narrower forward: a literal mode with only the notify
356+
# period forwarded. notifyPeriodInSeconds is already @fmtstring
357+
# under FEATURE_BUNDLE_1; the whole-field expression is int? and
358+
# a null result must drop the field (schema default applies).
359+
tmpl = _env_template(
360+
self._wrap_actions(
361+
{
362+
"mode": "NOTIFY_THEN_TERMINATE",
363+
"notifyPeriodInSeconds": (
364+
"{{WrappedAction.Cancelation.NotifyPeriodInSeconds}}"
365+
),
366+
}
367+
),
368+
extensions=self._ROUND_TRIP_EXTS,
369+
)
370+
_decode(tmpl, extensions=self._ROUND_TRIP_EXTS)
371+
372+
def test_fmtstring_mode_requires_feature_bundle_1(self):
373+
# The format-string mode form is gated on FEATURE_BUNDLE_1
374+
# (Template Schemas 5.3); with only WRAP_ACTIONS + EXPR it must
375+
# be rejected.
376+
tmpl = _env_template(
377+
self._wrap_actions({"mode": "{{WrappedAction.Cancelation.Mode}}"}),
378+
extensions=["WRAP_ACTIONS", "EXPR"],
379+
)
380+
with pytest.raises(DecodeValidationError, match="FEATURE_BUNDLE_1"):
381+
_decode(tmpl, extensions=["WRAP_ACTIONS", "EXPR"])
382+
383+
@pytest.mark.parametrize(
384+
"mode",
385+
[
386+
pytest.param("TERMIN{{WrappedAction.Cancelation.Mode}}", id="leading-text"),
387+
# Starts with "{{", ends with "}}", exactly one "{{" — but it is
388+
# the expression followed by literal text "Y}}"; the guard must
389+
# count closing braces too.
390+
pytest.param("{{WrappedAction.Cancelation.Mode}}Y}}", id="trailing-text"),
391+
],
392+
)
393+
def test_partial_fmtstring_mode_rejected(self, mode: str):
394+
# The mode must be a literal or a single whole-field expression;
395+
# partial interpolation has no meaningful semantics for a schema
396+
# selector.
397+
tmpl = _env_template(
398+
self._wrap_actions({"mode": mode}),
399+
extensions=self._ROUND_TRIP_EXTS,
400+
)
401+
with pytest.raises(DecodeValidationError, match="whole-field"):
402+
_decode(tmpl, extensions=self._ROUND_TRIP_EXTS)

0 commit comments

Comments
 (0)