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):
283285NotifyPeriodType = 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+
286313class 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
346356class 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+
360504ArgListType = 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),
0 commit comments