diff --git a/src/openjd/model/_internal/_create_job.py b/src/openjd/model/_internal/_create_job.py index 49e15732..4583035a 100644 --- a/src/openjd/model/_internal/_create_job.py +++ b/src/openjd/model/_internal/_create_job.py @@ -92,6 +92,10 @@ def instantiate_model( # noqa: C901 errors = list[InitErrorDetails]() instantiated_fields = dict[str, Any]() + # Apply pre-transform if defined + if model._job_creation_metadata.transform is not None: + model = model._job_creation_metadata.transform(model) + # Determine the target model to create as target_model = model.__class__ if model._job_creation_metadata.create_as is not None: diff --git a/src/openjd/model/_internal/_validator_functions.py b/src/openjd/model/_internal/_validator_functions.py index 90065d06..d90ce84a 100644 --- a/src/openjd/model/_internal/_validator_functions.py +++ b/src/openjd/model/_internal/_validator_functions.py @@ -14,7 +14,7 @@ def validate_int_fmtstring_field( ge: Optional[int] = None, *, context: Optional[ModelParsingContextInterface], -) -> Union[int, float, Decimal, FormatString]: +) -> Union[int, FormatString]: """Validates a field that is allowed to be either an integer, a string containing an integer, or a string containing expressions that resolve to an integer.""" value_type_wrong_msg = "Value must be an integer or a string containing an integer." @@ -57,7 +57,7 @@ def validate_float_fmtstring_field( ge: Optional[Decimal] = None, *, context: Optional[ModelParsingContextInterface], -) -> Union[int, float, Decimal, FormatString]: +) -> Union[Decimal, FormatString]: """Validates a field that is allowed to be either an float, a string containing an float, or a string containing expressions that resolve to a float.""" value_type_wrong_msg = "Value must be a float or a string containing a float." diff --git a/src/openjd/model/_types.py b/src/openjd/model/_types.py index ca0ae767..fa6499d3 100644 --- a/src/openjd/model/_types.py +++ b/src/openjd/model/_types.py @@ -268,6 +268,13 @@ class JobCreationMetadata: """This instructs the instantiation code to rename the given fields. """ + transform: Optional[Callable[["OpenJDModel"], "OpenJDModel"]] = field(default=None) + """A callable that transforms the source model before field processing. + arg0 - The model to transform. + returns - The transformed model (can be the same instance or a new one). + Use-case: Resolving syntax sugar on StepTemplate before creating Step. + """ + class OpenJDModel(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) diff --git a/src/openjd/model/v2023_09/__init__.py b/src/openjd/model/v2023_09/__init__.py index fdcf8ffb..c2f70ff7 100644 --- a/src/openjd/model/v2023_09/__init__.py +++ b/src/openjd/model/v2023_09/__init__.py @@ -29,6 +29,7 @@ EmbeddedFiles, EmbeddedFileText, EmbeddedFileTypes, + EndOfLine, Environment, EnvironmentActions, EnvironmentName, @@ -64,6 +65,8 @@ PathTaskParameterDefinition, RangeExpressionTaskParameterDefinition, RangeListTaskParameterDefinition, + ScriptInterpreter, + SimpleAction, Step, StepActions, StepEnvironmentList, @@ -113,6 +116,7 @@ "EmbeddedFiles", "EmbeddedFileText", "EmbeddedFileTypes", + "EndOfLine", "Environment", "EnvironmentActions", "EnvironmentScript", @@ -148,6 +152,8 @@ "PathTaskParameterDefinition", "RangeExpressionTaskParameterDefinition", "RangeListTaskParameterDefinition", + "ScriptInterpreter", + "SimpleAction", "Step", "StepActions", "StepEnvironmentList", diff --git a/src/openjd/model/v2023_09/_model.py b/src/openjd/model/v2023_09/_model.py index 890b7ed3..8204f8d9 100644 --- a/src/openjd/model/v2023_09/_model.py +++ b/src/openjd/model/v2023_09/_model.py @@ -3,6 +3,8 @@ from __future__ import annotations import re +import secrets +import string from decimal import Decimal, InvalidOperation from enum import Enum from graphlib import CycleError, TopologicalSorter @@ -61,6 +63,15 @@ _VALUE_LESS_THAN_MIN_ERROR = "Value less than minValue." _VALUE_LARGER_THAN_MAX_ERROR = "Value larger than maxValue." +# Interpreter syntax sugar configuration: (command, extension, arg_prefix) +_INTERPRETER_MAP: dict[str, tuple[str, str, list[str]]] = { + "python": ("python", ".py", []), + "bash": ("bash", ".sh", []), + "cmd": ("cmd", ".bat", ["/C"]), + "powershell": ("powershell", ".ps1", ["-File"]), + "node": ("node", ".js", []), +} + class ModelParsingContext(ModelParsingContextInterface): """Context required while parsing an OpenJDModel. An instance of this class @@ -92,10 +103,13 @@ class ExtensionName(str, Enum): This appears in the 'extensions' list property of all model instances. """ - # # https://github.com/OpenJobDescription/openjd-specifications/blob/mainline/rfcs/0001-task-chunking.md + # https://github.com/OpenJobDescription/openjd-specifications/blob/mainline/rfcs/0001-task-chunking.md TASK_CHUNKING = "TASK_CHUNKING" # Extension that enables the use of openjd_redacted_env for setting environment variables with redacted values in logs REDACTED_ENV_VARS = "REDACTED_ENV_VARS" + # Extension for increased limits, format strings in timeout/min/max/notifyPeriodInSeconds, + # endOfLine control, and script interpreter syntax sugar + FEATURE_BUNDLE_1 = "FEATURE_BUNDLE_1" ExtensionNameList = Annotated[list[str], Field(min_length=1)] @@ -187,6 +201,8 @@ class ValueReferenceConstants(Enum): class JobTemplateName(FormatString): _min_length = 1 + # Max length is validated after resolution in Job model, not here + # because the template name can contain format strings def __new__(cls, value: str, *, context: ModelParsingContextInterface = ModelParsingContext()): return super().__new__(cls, value, context=context) @@ -194,10 +210,10 @@ def __new__(cls, value: str, *, context: ModelParsingContextInterface = ModelPar JobName = Annotated[ str, - StringConstraints(min_length=1, max_length=128, strict=True, pattern=_standard_string_regex), + StringConstraints(min_length=1, max_length=512, strict=True, pattern=_standard_string_regex), ] Identifier = Annotated[ - str, StringConstraints(min_length=1, max_length=64, strict=True, pattern=_identifier_regex) + str, StringConstraints(min_length=1, max_length=512, strict=True, pattern=_identifier_regex) ] Description = Annotated[ str, @@ -212,11 +228,11 @@ def __new__(cls, value: str, *, context: ModelParsingContextInterface = ModelPar ] EnvironmentName = Annotated[ str, - StringConstraints(min_length=1, max_length=64, strict=True, pattern=_standard_string_regex), + StringConstraints(min_length=1, max_length=512, strict=True, pattern=_standard_string_regex), ] StepName = Annotated[ str, - StringConstraints(min_length=1, max_length=64, strict=True, pattern=_standard_string_regex), + StringConstraints(min_length=1, max_length=512, strict=True, pattern=_standard_string_regex), ] ParameterStringValue = Annotated[str, StringConstraints(min_length=0, max_length=1024, strict=True)] @@ -285,7 +301,33 @@ class CancelationMethodNotifyThenTerminate(OpenJDModel_v2023_09): """ mode: Literal[CancelationMode.NOTIFY_THEN_TERMINATE] - notifyPeriodInSeconds: Optional[NotifyPeriodType] = None # noqa: N815 + notifyPeriodInSeconds: Optional[Union[NotifyPeriodType, FormatString]] = None # noqa: N815 + + _job_creation_metadata = JobCreationMetadata(resolve_fields={"notifyPeriodInSeconds"}) + + @field_validator("notifyPeriodInSeconds", mode="before") + @classmethod + def _validate_notify_period( + cls, v: Any, info: ValidationInfo + ) -> Optional[Union[int, FormatString]]: + if v is None: + return v + context = cast(Optional[ModelParsingContext], info.context) + if isinstance(v, str): + if context and "FEATURE_BUNDLE_1" not in context.extensions: + # Try to parse as int, fail if not + try: + return int(v) + except ValueError: + raise ValueError( + "notifyPeriodInSeconds as a format string requires the FEATURE_BUNDLE_1 extension." + ) + return validate_int_fmtstring_field(v, ge=1, context=context) + if isinstance(v, int): + if v < 1 or v > 600: + raise ValueError("notifyPeriodInSeconds must be between 1 and 600") + return v + return v class CancelationMethodTerminate(OpenJDModel_v2023_09): @@ -313,6 +355,7 @@ class Action(OpenJDModel_v2023_09): args (Optional[list[FormatString]]): The arguments that are provided to the command when it is run. timeout (Optional[int]): Maximum allowed runtime of the Action in seconds. + Can be a format string with FEATURE_BUNDLE_1 extension. Default: No timeout cancelation (Optional[Union[CancelationMethodNotifyThenTerminate, CancelationMethodTerminate]]): If defined, provides details regarding how this action should be canceled. @@ -321,11 +364,35 @@ class Action(OpenJDModel_v2023_09): command: CommandString args: Optional[ArgListType] = None - timeout: Optional[PositiveInt] = None + timeout: Optional[Union[PositiveInt, FormatString]] = None cancelation: Optional[ Union[CancelationMethodNotifyThenTerminate, CancelationMethodTerminate] ] = Field(None, discriminator="mode") + _job_creation_metadata = JobCreationMetadata(resolve_fields={"timeout"}) + + @field_validator("timeout", mode="before") + @classmethod + def _validate_timeout(cls, v: Any, info: ValidationInfo) -> Optional[Union[int, FormatString]]: + if v is None: + return v + context = cast(Optional[ModelParsingContext], info.context) + if isinstance(v, str): + if context and "FEATURE_BUNDLE_1" not in context.extensions: + # Try to parse as int, fail if not + try: + return int(v) + except ValueError: + raise ValueError( + "timeout as a format string requires the FEATURE_BUNDLE_1 extension." + ) + return validate_int_fmtstring_field(v, ge=1, context=context) + if isinstance(v, int): + if v < 1: + raise ValueError("timeout must be a positive integer") + return v + return v + class StepActions(OpenJDModel_v2023_09): """The Actions for Tasks of a Step. @@ -373,8 +440,16 @@ class EmbeddedFileTypes(str, Enum): TEXT = "TEXT" +class EndOfLine(str, Enum): + """Line ending style for embedded files.""" + + AUTO = "AUTO" + LF = "LF" + CRLF = "CRLF" + + # TODO - regex of allowable filename characters -Filename = Annotated[str, StringConstraints(min_length=1, max_length=64, strict=True)] +Filename = Annotated[str, StringConstraints(min_length=1, max_length=256, strict=True)] class DataString(FormatString): @@ -398,6 +473,8 @@ class EmbeddedFileText(OpenJDModel_v2023_09): will have its execute-permissions set. Default: False data (FormatString): The text data to write to the file. + endOfLine (Optional[EndOfLine]): Line ending style. Requires FEATURE_BUNDLE_1 extension. + Default: AUTO """ name: Identifier @@ -405,6 +482,7 @@ class EmbeddedFileText(OpenJDModel_v2023_09): data: DataString filename: Optional[Filename] = None runnable: Optional[StrictBool] = None + endOfLine: Optional[EndOfLine] = None # noqa: N815 _template_variable_definitions = DefinesTemplateVariables( defines={TemplateVariableDef(prefix="File.", resolves=ResolutionScope.SESSION)}, @@ -415,12 +493,91 @@ class EmbeddedFileText(OpenJDModel_v2023_09): "data": {"__self__"}, } + @field_validator("name") + @classmethod + def _validate_name(cls, v: str, info: ValidationInfo) -> str: + context = cast(Optional[ModelParsingContext], info.context) + max_len = 512 if context and "FEATURE_BUNDLE_1" in context.extensions else 64 + if len(v) > max_len: + raise ValueError(f"name must be at most {max_len} characters long") + return v + + @field_validator("filename") + @classmethod + def _validate_filename(cls, v: Optional[Filename], info: ValidationInfo) -> Optional[Filename]: + if v is None: + return v + context = cast(Optional[ModelParsingContext], info.context) + max_len = 256 if context and "FEATURE_BUNDLE_1" in context.extensions else 64 + if len(v) > max_len: + raise ValueError(f"String must be at most {max_len} characters long") + return v + + @field_validator("endOfLine") + @classmethod + def _validate_end_of_line( + cls, v: Optional[EndOfLine], info: ValidationInfo + ) -> Optional[EndOfLine]: + if v is None: + return v + context = cast(Optional[ModelParsingContext], info.context) + # Skip extension check if no context (e.g., during job creation from validated template) + if context and "FEATURE_BUNDLE_1" not in context.extensions: + raise ValueError("The endOfLine property requires the FEATURE_BUNDLE_1 extension.") + return v + # --------------------- Script types ---------------------------- EmbeddedFiles = Annotated[list[EmbeddedFileText], Field(min_length=1)] +class ScriptInterpreter(str, Enum): + """Script interpreter types for SimpleAction syntax sugar.""" + + PYTHON = "python" + BASH = "bash" + CMD = "cmd" + POWERSHELL = "powershell" + NODE = "node" + + +class SimpleAction(OpenJDModel_v2023_09): + """Syntax sugar for a script action with a specific interpreter. + + This is only available with the FEATURE_BUNDLE_1 extension. + + Attributes: + script (DataString): The script content to execute. + args (Optional[list[ArgString]]): Additional arguments to pass to the interpreter. + timeout (Optional[Union[int, FormatString]]): Maximum allowed runtime in seconds. + Can be a format string. + cancelation (Optional[CancelationMethod]): How to cancel the action. + """ + + script: DataString + args: Optional[ArgListType] = None + timeout: Optional[Union[PositiveInt, FormatString]] = None + cancelation: Optional[ + Union[CancelationMethodNotifyThenTerminate, CancelationMethodTerminate] + ] = Field(None, discriminator="mode") + + @field_validator("timeout", mode="before") + @classmethod + def _validate_timeout(cls, v: Any, info: ValidationInfo) -> Optional[Union[int, FormatString]]: + if v is None: + return v + context = cast(Optional[ModelParsingContext], info.context) + if isinstance(v, str): + # SimpleAction always requires FEATURE_BUNDLE_1, so format strings are allowed + return validate_int_fmtstring_field(v, ge=1, context=context) + if isinstance(v, int): + if v < 1: + raise ValueError("timeout must be a positive integer") + return v + return v + + class StepScript(OpenJDModel_v2023_09): """The Step Script is the information on what Actions to perform when running a Task for a Step. @@ -1052,6 +1209,15 @@ class Environment(OpenJDModel_v2023_09): _template_variable_scope = ResolutionScope.SESSION + @field_validator("name") + @classmethod + def _validate_environment_name(cls, v: str, info: ValidationInfo) -> str: + context = cast(Optional[ModelParsingContext], info.context) + max_len = 512 if context and "FEATURE_BUNDLE_1" in context.extensions else 64 + if len(v) > max_len: + raise ValueError(f"name must be at most {max_len} characters long") + return v + @model_validator(mode="before") @classmethod def _validate_has_script_or_variables(cls, values: dict[str, Any]) -> dict[str, Any]: @@ -2146,12 +2312,12 @@ class AmountRequirementTemplate(OpenJDModel_v2023_09): """ name: AmountCapabilityName - min: Optional[Decimal] = None - max: Optional[Decimal] = None + min: Optional[Union[Decimal, FormatString]] = None + max: Optional[Union[Decimal, FormatString]] = None _job_creation_metadata = JobCreationMetadata( create_as=JobCreateAsMetadata(model=AmountRequirement), - resolve_fields={"name"}, + resolve_fields={"name", "min", "max"}, ) @field_validator("name") @@ -2162,26 +2328,74 @@ def _validate_name(cls, v: AmountCapabilityName, info: ValidationInfo) -> Amount ) return v - @field_validator("min") + @field_validator("min", mode="before") @classmethod - def _validate_min(cls, v: Optional[Decimal]) -> Optional[Decimal]: + def _validate_min( + cls, v: Optional[Any], info: ValidationInfo + ) -> Optional[Union[Decimal, FormatString]]: if v is None: return v - if v < 0: - raise ValueError(f"Value {v} must be zero or greater") - return v - - @field_validator("max") + context = cast(Optional[ModelParsingContext], info.context) + if isinstance(v, str): + if context and "FEATURE_BUNDLE_1" not in context.extensions: + # Try to parse as Decimal, fail if not + try: + dec_val = Decimal(v) + if dec_val < 0: + raise ValueError("Value must be zero or greater") + return dec_val + except InvalidOperation: + raise ValueError( + "min as a format string requires the FEATURE_BUNDLE_1 extension." + ) + return validate_float_fmtstring_field(v, ge=Decimal(0), context=context) + if isinstance(v, (int, float, Decimal)) and not isinstance(v, bool): + dec_val = Decimal(str(v)) + if dec_val < 0: + raise ValueError(f"Value {v} must be zero or greater") + return dec_val + raise ValueError("Value must be a number or string") + + @field_validator("max", mode="before") @classmethod - def _validate_max(cls, v: Optional[Decimal], info: ValidationInfo) -> Optional[Decimal]: + def _validate_max( + cls, v: Optional[Any], info: ValidationInfo + ) -> Optional[Union[Decimal, FormatString]]: if v is None: return v - if v <= 0: - raise ValueError("Value must be greater than 0") - v_min = info.data.get("min") - if v_min is not None and v_min > v: - raise ValueError("Value for 'max' must be greater or equal to 'min'") - return v + context = cast(Optional[ModelParsingContext], info.context) + if isinstance(v, str): + if context and "FEATURE_BUNDLE_1" not in context.extensions: + # Try to parse as Decimal, fail if not + try: + dec_val = Decimal(v) + if dec_val <= 0: + raise ValueError("Value must be greater than 0") + return dec_val + except InvalidOperation: + raise ValueError( + "max as a format string requires the FEATURE_BUNDLE_1 extension." + ) + return validate_float_fmtstring_field(v, ge=Decimal(0), context=context) + if isinstance(v, (int, float, Decimal)) and not isinstance(v, bool): + dec_val = Decimal(str(v)) + if dec_val <= 0: + raise ValueError("Value must be greater than 0") + return dec_val + raise ValueError("Value must be a number or string") + + @model_validator(mode="after") + def _validate_min_max_relationship(self) -> Self: + # Can only validate relationship if both are concrete values (not format strings) + if ( + self.min is not None + and self.max is not None + and not isinstance(self.min, FormatString) + and not isinstance(self.max, FormatString) + ): + if self.min > self.max: + raise ValueError("Value for 'max' must be greater or equal to 'min'") + return self @model_validator(mode="before") @classmethod @@ -2434,21 +2648,90 @@ class StepTemplate(OpenJDModel_v2023_09): hostRequirements (Optional[HostRequirementsTemplate]): The capabilities that a host requires for this Step to run on it. dependencies (Optional[StepDependenciesList]): A list of this Step's dependencies. + python (Optional[SimpleAction]): Python script syntax sugar (FEATURE_BUNDLE_1). + bash (Optional[SimpleAction]): Bash script syntax sugar (FEATURE_BUNDLE_1). + cmd (Optional[SimpleAction]): Windows cmd script syntax sugar (FEATURE_BUNDLE_1). + powershell (Optional[SimpleAction]): PowerShell script syntax sugar (FEATURE_BUNDLE_1). + node (Optional[SimpleAction]): Node.js script syntax sugar (FEATURE_BUNDLE_1). """ name: StepName description: Optional[Description] = None - script: StepScript + script: Optional[StepScript] = None stepEnvironments: Optional[StepEnvironmentList] = None parameterSpace: Optional[StepParameterSpaceDefinition] = None # noqa: N815 hostRequirements: Optional[HostRequirementsTemplate] = None dependencies: Optional[StepDependenciesList] = None + python: Optional[SimpleAction] = None + bash: Optional[SimpleAction] = None + cmd: Optional[SimpleAction] = None + powershell: Optional[SimpleAction] = None + node: Optional[SimpleAction] = None _template_variable_sources = { "script": {"__self__", "parameterSpace"}, "stepEnvironments": {"__self__"}, + "python": {"__self__", "parameterSpace"}, + "bash": {"__self__", "parameterSpace"}, + "cmd": {"__self__", "parameterSpace"}, + "powershell": {"__self__", "parameterSpace"}, + "node": {"__self__", "parameterSpace"}, } - _job_creation_metadata = JobCreationMetadata(create_as=JobCreateAsMetadata(model=Step)) + _job_creation_metadata = JobCreationMetadata( + create_as=JobCreateAsMetadata(model=Step), + exclude_fields={"python", "bash", "cmd", "powershell", "node"}, + transform=lambda t: cast("StepTemplate", t).resolve_syntax_sugar(), + ) + + @field_validator("name") + @classmethod + def _validate_step_name(cls, v: str, info: ValidationInfo) -> str: + context = cast(Optional[ModelParsingContext], info.context) + max_len = 512 if context and "FEATURE_BUNDLE_1" in context.extensions else 64 + if len(v) > max_len: + raise ValueError(f"name must be at most {max_len} characters long") + return v + + @model_validator(mode="before") + @classmethod + def _validate_script_or_interpreter( + cls, values: dict[str, Any], info: ValidationInfo + ) -> dict[str, Any]: + if not isinstance(values, dict): + raise ValueError("Expected a dictionary of values") + + context = cast(Optional[ModelParsingContext], info.context) + interpreter_keys = {i.value for i in ScriptInterpreter} + has_script = "script" in values + has_interpreter = any(k in values for k in interpreter_keys) + interpreter_count = sum(1 for k in interpreter_keys if k in values) + + # Check for FEATURE_BUNDLE_1 extension if using interpreter syntax sugar + if has_interpreter: + if context and "FEATURE_BUNDLE_1" not in context.extensions: + raise ValueError( + "Script interpreter syntax sugar (python, bash, cmd, powershell, node) " + "requires the FEATURE_BUNDLE_1 extension." + ) + + # Must have exactly one of: script or an interpreter + if has_script and has_interpreter: + raise ValueError( + "Cannot specify both 'script' and script interpreter " + "(python, bash, cmd, powershell, node)." + ) + if not has_script and not has_interpreter: + raise ValueError( + "Must specify either 'script' or a script interpreter " + "(python, bash, cmd, powershell, node)." + ) + if interpreter_count > 1: + raise ValueError( + "Cannot specify multiple script interpreters. " + "Choose one of: python, bash, cmd, powershell, node." + ) + + return values @field_validator("dependencies") @classmethod @@ -2482,6 +2765,70 @@ def _validate_no_self_dependency(self) -> Self: raise ValueError("A step cannot depend upon itself.") return self + def resolve_syntax_sugar(self) -> "StepTemplate": + """Transform interpreter syntax sugar into equivalent script + embeddedFiles. + + If this StepTemplate uses script interpreter syntax sugar (python, bash, cmd, + powershell, node) introduced in RFC 0004 (FEATURE_BUNDLE_1), returns a new + StepTemplate with the equivalent script and embeddedFiles structure. + If already using script, returns self unchanged. + + Returns: + StepTemplate: A new StepTemplate with de-sugared script, or self if no sugar. + """ + if self.script: + return self + + for name, (command, ext, arg_prefix) in _INTERPRETER_MAP.items(): + simple_action = getattr(self, name, None) + if simple_action is not None: + break + else: + return self + + # Generate unique embedded file name from step name + random suffix + # Max filename is 256, reserve 7 for "_" + 6-char suffix, and len(ext) for extension + safe_name = re.sub(r"[^a-zA-Z0-9]", "_", self.name) + max_name_len = 256 - 7 - len(ext) + safe_name = safe_name[:max_name_len] + suffix = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(6)) + embedded_name = f"{safe_name}_{suffix}" + file_ref = f"{{{{Task.File.{embedded_name}}}}}" + + # Build args: prefix + file reference + user args + args: list[ArgString] = [*(ArgString(arg) for arg in arg_prefix), ArgString(file_ref)] + if simple_action.args: + args.extend(simple_action.args) + + # Construct directly - inputs are already validated + return StepTemplate.model_construct( + name=self.name, + description=self.description, + script=StepScript.model_construct( + actions=StepActions.model_construct( + onRun=Action.model_construct( + command=CommandString(command), + args=args, + timeout=simple_action.timeout, + cancelation=simple_action.cancelation, + ) + ), + embeddedFiles=[ + EmbeddedFileText.model_construct( + name=embedded_name, + type=EmbeddedFileTypes.TEXT, + filename=f"{embedded_name}{ext}", + runnable=True, + data=simple_action.script, + ) + ], + ), + stepEnvironments=self.stepEnvironments, + parameterSpace=self.parameterSpace, + hostRequirements=self.hostRequirements, + dependencies=self.dependencies, + ) + StepTemplateList = Annotated[list[StepTemplate], Field(min_length=1)] JobParameterDefinitionList = Annotated[ @@ -2498,7 +2845,7 @@ def _validate_no_self_dependency(self) -> Self: ], Field( min_length=1, - max_length=50, + max_length=200, # Extended limit; base limit of 50 is validated in JobTemplate ), ] JobEnvironmentsList = Annotated[list[Environment], Field(min_length=1)] @@ -2515,6 +2862,15 @@ class Job(OpenJDModel_v2023_09): jobEnvironments: Optional[JobEnvironmentsList] = None extensions: Optional[list[ExtensionName]] = None + @field_validator("name") + @classmethod + def _validate_job_name_length(cls, v: str, info: ValidationInfo) -> str: + context = cast(Optional[ModelParsingContext], info.context) + max_len = 512 if context and "FEATURE_BUNDLE_1" in context.extensions else 128 + if len(v) > max_len: + raise ValueError(f"String should have at most {max_len} characters") + return v + class JobTemplate(OpenJDModel_v2023_09): """Definition of an Open Job Description Job Template. @@ -2558,6 +2914,18 @@ class JobTemplate(OpenJDModel_v2023_09): rename_fields={"parameterDefinitions": "parameters"}, ) + @field_validator("name") + @classmethod + def _validate_job_name_length(cls, v: JobTemplateName, info: ValidationInfo) -> JobTemplateName: + # Only validate length if there are no expressions to resolve + if len(v.expressions) > 0: + return v + context = cast(Optional[ModelParsingContext], info.context) + max_len = 512 if context and "FEATURE_BUNDLE_1" in context.extensions else 128 + if len(v) > max_len: + raise ValueError(f"name must be at most {max_len} characters long") + return v + @field_validator("extensions") @classmethod def _unique_extension_names( @@ -2605,10 +2973,18 @@ def _unique_step_names(cls, v: StepTemplateList) -> StepTemplateList: @field_validator("parameterDefinitions") @classmethod - def _unique_parameter_names( - cls, v: Optional[JobParameterDefinitionList] + def _validate_parameter_definitions( + cls, v: Optional[JobParameterDefinitionList], info: ValidationInfo ) -> Optional[JobParameterDefinitionList]: if v is not None: + # Validate max length based on extension + context = cast(Optional[ModelParsingContext], info.context) + max_len = 200 if context and "FEATURE_BUNDLE_1" in context.extensions else 50 + if len(v) > max_len: + raise ValueError( + f"parameterDefinitions must have at most {max_len} elements" + + (" (use FEATURE_BUNDLE_1 extension for up to 200)" if max_len == 50 else "") + ) return validate_unique_elements(v, item_value=lambda v: v.name, property="name") return v @@ -2785,10 +3161,13 @@ def _permitted_extension_names( @field_validator("parameterDefinitions") @classmethod - def _unique_parameter_names( - cls, v: Optional[JobParameterDefinitionList] + def _validate_parameter_definitions( + cls, v: Optional[JobParameterDefinitionList], info: ValidationInfo ) -> Optional[JobParameterDefinitionList]: if v is not None: + # EnvironmentTemplate always has max 50 parameters (no extension increases this) + if len(v) > 50: + raise ValueError("parameterDefinitions must have at most 50 elements") return validate_unique_elements(v, item_value=lambda v: v.name, property="name") return v diff --git a/test/openjd/model/test_parse.py b/test/openjd/model/test_parse.py index 80437a42..a7ea7454 100644 --- a/test/openjd/model/test_parse.py +++ b/test/openjd/model/test_parse.py @@ -194,6 +194,7 @@ class MockExtensionName(str, Enum): """A mock enum with only SUPPORTED_NAME for testing.""" SUPPORTED_NAME = "SUPPORTED_NAME" + FEATURE_BUNDLE_1 = "FEATURE_BUNDLE_1" class MockExtensionNameWithTwoNames(str, Enum): @@ -201,6 +202,7 @@ class MockExtensionNameWithTwoNames(str, Enum): SUPPORTED_NAME = "SUPPORTED_NAME" ANOTHER_SUPPORTED_NAME = "ANOTHER_SUPPORTED_NAME" + FEATURE_BUNDLE_1 = "FEATURE_BUNDLE_1" @pytest.mark.parametrize( diff --git a/test/openjd/model/v2023_09/test_feature_bundle_1.py b/test/openjd/model/v2023_09/test_feature_bundle_1.py new file mode 100644 index 00000000..c165bbe7 --- /dev/null +++ b/test/openjd/model/v2023_09/test_feature_bundle_1.py @@ -0,0 +1,1137 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + +import pytest +from pydantic import ValidationError + +from openjd.model import create_job +from openjd.model._parse import _parse_model +from openjd.model.v2023_09 import ( + Action, + ArgString, + AmountRequirementTemplate, + CancelationMethodNotifyThenTerminate, + EmbeddedFileText, + ExtensionName, + JobTemplate, + EnvironmentTemplate, + ModelParsingContext, + SimpleAction, + Step, + StepTemplate, +) + + +# Helper to create context with FEATURE_BUNDLE_1 extension +def fb1_context() -> ModelParsingContext: + return ModelParsingContext(supported_extensions=[ExtensionName.FEATURE_BUNDLE_1]) + + +# Minimal valid data for reuse +STEP_SCRIPT = {"actions": {"onRun": {"command": "echo"}}} + + +class TestFeatureBundle1Extension: + """Tests for FEATURE_BUNDLE_1 extension registration and support.""" + + def test_extension_supported(self) -> None: + """Test that FEATURE_BUNDLE_1 extension can be used in a job template.""" + data = { + "specificationVersion": "jobtemplate-2023-09", + "extensions": ["FEATURE_BUNDLE_1"], + "name": "Test Job", + "steps": [{"name": "step1", "script": STEP_SCRIPT}], + } + _parse_model(model=JobTemplate, obj=data, context=fb1_context()) + + def test_extension_not_supported(self) -> None: + """Test that using FEATURE_BUNDLE_1 fails when not supported.""" + data = { + "specificationVersion": "jobtemplate-2023-09", + "extensions": ["FEATURE_BUNDLE_1"], + "name": "Test Job", + "steps": [{"name": "step1", "script": STEP_SCRIPT}], + } + with pytest.raises(ValidationError) as excinfo: + _parse_model(model=JobTemplate, obj=data, context=ModelParsingContext()) + assert "FEATURE_BUNDLE_1" in str(excinfo.value) + + +class TestActionTimeoutFormatString: + """Tests for timeout format string support in Action.""" + + def test_timeout_format_string_with_extension(self) -> None: + """Test that timeout can be a format string with FEATURE_BUNDLE_1.""" + data = {"command": "echo", "timeout": "{{Param.Timeout}}"} + result = _parse_model(model=Action, obj=data, context=fb1_context()) + assert str(result.timeout) == "{{Param.Timeout}}" + + def test_timeout_format_string_without_extension_fails(self) -> None: + """Test that timeout format string fails without extension.""" + data = {"command": "echo", "timeout": "{{Param.Timeout}}"} + with pytest.raises(ValidationError) as excinfo: + _parse_model(model=Action, obj=data, context=ModelParsingContext()) + assert "FEATURE_BUNDLE_1" in str(excinfo.value) + + def test_timeout_int_string_without_extension(self) -> None: + """Test that timeout as int string works without extension.""" + data = {"command": "echo", "timeout": "60"} + result = _parse_model(model=Action, obj=data, context=ModelParsingContext()) + assert result.timeout == 60 + + def test_timeout_int_with_extension(self) -> None: + """Test that timeout as int still works with extension.""" + data = {"command": "echo", "timeout": 60} + result = _parse_model(model=Action, obj=data, context=fb1_context()) + assert result.timeout == 60 + + +class TestNotifyPeriodFormatString: + """Tests for notifyPeriodInSeconds format string support.""" + + def test_notify_period_format_string_with_extension(self) -> None: + """Test that notifyPeriodInSeconds can be a format string with FEATURE_BUNDLE_1.""" + data = {"mode": "NOTIFY_THEN_TERMINATE", "notifyPeriodInSeconds": "{{Param.Period}}"} + result = _parse_model( + model=CancelationMethodNotifyThenTerminate, obj=data, context=fb1_context() + ) + assert str(result.notifyPeriodInSeconds) == "{{Param.Period}}" + + def test_notify_period_format_string_without_extension_fails(self) -> None: + """Test that notifyPeriodInSeconds format string fails without extension.""" + data = {"mode": "NOTIFY_THEN_TERMINATE", "notifyPeriodInSeconds": "{{Param.Period}}"} + with pytest.raises(ValidationError) as excinfo: + _parse_model( + model=CancelationMethodNotifyThenTerminate, obj=data, context=ModelParsingContext() + ) + assert "FEATURE_BUNDLE_1" in str(excinfo.value) + + def test_notify_period_int_string_without_extension(self) -> None: + """Test that notifyPeriodInSeconds as int string works without extension.""" + data = {"mode": "NOTIFY_THEN_TERMINATE", "notifyPeriodInSeconds": "120"} + result = _parse_model( + model=CancelationMethodNotifyThenTerminate, obj=data, context=ModelParsingContext() + ) + assert result.notifyPeriodInSeconds == 120 + + +class TestAmountRequirementFormatStrings: + """Tests for min/max format string support in AmountRequirementTemplate.""" + + def test_min_format_string_with_extension(self) -> None: + """Test that min can be a format string with FEATURE_BUNDLE_1.""" + data = {"name": "amount.worker.vcpu", "min": "{{Param.CpuMin}}"} + result = _parse_model(model=AmountRequirementTemplate, obj=data, context=fb1_context()) + assert str(result.min) == "{{Param.CpuMin}}" + + def test_max_format_string_with_extension(self) -> None: + """Test that max can be a format string with FEATURE_BUNDLE_1.""" + data = {"name": "amount.worker.vcpu", "max": "{{Param.CpuMax}}"} + result = _parse_model(model=AmountRequirementTemplate, obj=data, context=fb1_context()) + assert str(result.max) == "{{Param.CpuMax}}" + + def test_min_format_string_without_extension_fails(self) -> None: + """Test that min format string fails without extension.""" + data = {"name": "amount.worker.vcpu", "min": "{{Param.CpuMin}}"} + with pytest.raises(ValidationError) as excinfo: + _parse_model(model=AmountRequirementTemplate, obj=data, context=ModelParsingContext()) + assert "FEATURE_BUNDLE_1" in str(excinfo.value) + + def test_max_format_string_without_extension_fails(self) -> None: + """Test that max format string fails without extension.""" + data = {"name": "amount.worker.vcpu", "max": "{{Param.CpuMax}}"} + with pytest.raises(ValidationError) as excinfo: + _parse_model(model=AmountRequirementTemplate, obj=data, context=ModelParsingContext()) + assert "FEATURE_BUNDLE_1" in str(excinfo.value) + + def test_min_decimal_string_without_extension(self) -> None: + """Test that min as decimal string works without extension.""" + data = {"name": "amount.worker.vcpu", "min": "2.5"} + result = _parse_model( + model=AmountRequirementTemplate, obj=data, context=ModelParsingContext() + ) + assert result.min is not None + assert float(result.min) == 2.5 + + def test_min_max_both_format_strings(self) -> None: + """Test that both min and max can be format strings.""" + data = { + "name": "amount.worker.vcpu", + "min": "{{Param.CpuMin}}", + "max": "{{Param.CpuMax}}", + } + result = _parse_model(model=AmountRequirementTemplate, obj=data, context=fb1_context()) + assert str(result.min) == "{{Param.CpuMin}}" + assert str(result.max) == "{{Param.CpuMax}}" + + +class TestEndOfLine: + """Tests for endOfLine property in EmbeddedFileText.""" + + @pytest.mark.parametrize("eol", ["AUTO", "LF", "CRLF"]) + def test_end_of_line_with_extension(self, eol: str) -> None: + """Test that endOfLine can be set with FEATURE_BUNDLE_1.""" + data = {"name": "run", "type": "TEXT", "data": "echo hello", "endOfLine": eol} + result = _parse_model(model=EmbeddedFileText, obj=data, context=fb1_context()) + assert result.endOfLine is not None + assert result.endOfLine.value == eol + + def test_end_of_line_without_extension_fails(self) -> None: + """Test that endOfLine fails without extension.""" + data = {"name": "run", "type": "TEXT", "data": "echo hello", "endOfLine": "LF"} + with pytest.raises(ValidationError) as excinfo: + _parse_model(model=EmbeddedFileText, obj=data, context=ModelParsingContext()) + assert "FEATURE_BUNDLE_1" in str(excinfo.value) + + def test_end_of_line_invalid_value(self) -> None: + """Test that invalid endOfLine value fails.""" + data = {"name": "run", "type": "TEXT", "data": "echo hello", "endOfLine": "INVALID"} + with pytest.raises(ValidationError): + _parse_model(model=EmbeddedFileText, obj=data, context=fb1_context()) + + +class TestFilenameExtendedLength: + """Tests for extended filename length with FEATURE_BUNDLE_1.""" + + def test_filename_256_chars_with_extension(self) -> None: + """Test that filename can be 256 chars with FEATURE_BUNDLE_1.""" + data = {"name": "run", "type": "TEXT", "data": "echo", "filename": "f" * 256} + result = _parse_model(model=EmbeddedFileText, obj=data, context=fb1_context()) + assert result.filename is not None + assert len(result.filename) == 256 + + def test_filename_65_chars_without_extension_fails(self) -> None: + """Test that filename > 64 chars fails without extension.""" + data = {"name": "run", "type": "TEXT", "data": "echo", "filename": "f" * 65} + with pytest.raises(ValidationError): + _parse_model(model=EmbeddedFileText, obj=data, context=ModelParsingContext()) + + def test_filename_257_chars_with_extension_fails(self) -> None: + """Test that filename > 256 chars fails even with extension.""" + data = {"name": "run", "type": "TEXT", "data": "echo", "filename": "f" * 257} + with pytest.raises(ValidationError): + _parse_model(model=EmbeddedFileText, obj=data, context=fb1_context()) + + +class TestParameterDefinitionsCount: + """Tests for extended parameterDefinitions count with FEATURE_BUNDLE_1.""" + + def test_51_params_with_extension(self) -> None: + """Test that 51 params works with FEATURE_BUNDLE_1.""" + params = [{"name": f"P{i}", "type": "INT", "default": i} for i in range(51)] + data = { + "specificationVersion": "jobtemplate-2023-09", + "extensions": ["FEATURE_BUNDLE_1"], + "name": "Test", + "parameterDefinitions": params, + "steps": [{"name": "s", "script": STEP_SCRIPT}], + } + result = _parse_model(model=JobTemplate, obj=data, context=fb1_context()) + assert result.parameterDefinitions is not None + assert len(result.parameterDefinitions) == 51 + + def test_51_params_without_extension_fails(self) -> None: + """Test that 51 params fails without extension.""" + params = [{"name": f"P{i}", "type": "INT", "default": i} for i in range(51)] + data = { + "specificationVersion": "jobtemplate-2023-09", + "name": "Test", + "parameterDefinitions": params, + "steps": [{"name": "s", "script": STEP_SCRIPT}], + } + with pytest.raises(ValidationError) as excinfo: + _parse_model(model=JobTemplate, obj=data, context=ModelParsingContext()) + assert "50" in str(excinfo.value) + + def test_200_params_with_extension(self) -> None: + """Test that 200 params works with FEATURE_BUNDLE_1.""" + params = [{"name": f"P{i}", "type": "INT", "default": i} for i in range(200)] + data = { + "specificationVersion": "jobtemplate-2023-09", + "extensions": ["FEATURE_BUNDLE_1"], + "name": "Test", + "parameterDefinitions": params, + "steps": [{"name": "s", "script": STEP_SCRIPT}], + } + result = _parse_model(model=JobTemplate, obj=data, context=fb1_context()) + assert result.parameterDefinitions is not None + assert len(result.parameterDefinitions) == 200 + + def test_201_params_with_extension_fails(self) -> None: + """Test that 201 params fails even with extension.""" + params = [{"name": f"P{i}", "type": "INT", "default": i} for i in range(201)] + data = { + "specificationVersion": "jobtemplate-2023-09", + "extensions": ["FEATURE_BUNDLE_1"], + "name": "Test", + "parameterDefinitions": params, + "steps": [{"name": "s", "script": STEP_SCRIPT}], + } + with pytest.raises(ValidationError): + _parse_model(model=JobTemplate, obj=data, context=fb1_context()) + + def test_environment_template_51_params_without_extension_fails(self) -> None: + """Test that EnvironmentTemplate with 51 params fails without extension.""" + params = [{"name": f"P{i}", "type": "INT", "default": i} for i in range(51)] + data = { + "specificationVersion": "environment-2023-09", + "parameterDefinitions": params, + "environment": {"name": "Env", "script": {"actions": {"onEnter": {"command": "echo"}}}}, + } + with pytest.raises(ValidationError) as excinfo: + _parse_model(model=EnvironmentTemplate, obj=data, context=ModelParsingContext()) + assert "50" in str(excinfo.value) + + def test_environment_template_50_params_without_extension_succeeds(self) -> None: + """Test that EnvironmentTemplate with 50 params succeeds without extension.""" + params = [{"name": f"P{i}", "type": "INT", "default": i} for i in range(50)] + data = { + "specificationVersion": "environment-2023-09", + "parameterDefinitions": params, + "environment": {"name": "Env", "script": {"actions": {"onEnter": {"command": "echo"}}}}, + } + result = _parse_model(model=EnvironmentTemplate, obj=data, context=ModelParsingContext()) + assert result.parameterDefinitions is not None + assert len(result.parameterDefinitions) == 50 + + def test_environment_template_50_params_with_extension_succeeds(self) -> None: + """Test that EnvironmentTemplate with 50 params succeeds with extension.""" + params = [{"name": f"P{i}", "type": "INT", "default": i} for i in range(50)] + data = { + "specificationVersion": "environment-2023-09", + "extensions": ["FEATURE_BUNDLE_1"], + "parameterDefinitions": params, + "environment": {"name": "Env", "script": {"actions": {"onEnter": {"command": "echo"}}}}, + } + result = _parse_model(model=EnvironmentTemplate, obj=data, context=fb1_context()) + assert result.parameterDefinitions is not None + assert len(result.parameterDefinitions) == 50 + + def test_environment_template_51_params_with_extension_fails(self) -> None: + """Test that EnvironmentTemplate with 51 params fails even with extension.""" + params = [{"name": f"P{i}", "type": "INT", "default": i} for i in range(51)] + data = { + "specificationVersion": "environment-2023-09", + "extensions": ["FEATURE_BUNDLE_1"], + "parameterDefinitions": params, + "environment": {"name": "Env", "script": {"actions": {"onEnter": {"command": "echo"}}}}, + } + with pytest.raises(ValidationError) as excinfo: + _parse_model(model=EnvironmentTemplate, obj=data, context=fb1_context()) + assert "50" in str(excinfo.value) + + +class TestSimpleAction: + """Tests for SimpleAction model.""" + + def test_simple_action_parse(self) -> None: + """Test that SimpleAction parses correctly.""" + data = {"script": "print('hello')"} + result = _parse_model(model=SimpleAction, obj=data, context=fb1_context()) + assert "print" in str(result.script) + + def test_simple_action_with_args(self) -> None: + """Test SimpleAction with args.""" + data = {"script": "print('hello')", "args": ["--verbose"]} + result = _parse_model(model=SimpleAction, obj=data, context=fb1_context()) + assert result.args is not None + assert result.args[0] == "--verbose" + + def test_simple_action_with_timeout(self) -> None: + """Test SimpleAction with timeout.""" + data = {"script": "print('hello')", "timeout": 60} + result = _parse_model(model=SimpleAction, obj=data, context=fb1_context()) + assert result.timeout == 60 + + def test_simple_action_with_timeout_format_string(self) -> None: + """Test SimpleAction with timeout as format string.""" + data = {"script": "print('hello')", "timeout": "{{Param.Timeout}}"} + result = _parse_model(model=SimpleAction, obj=data, context=fb1_context()) + assert str(result.timeout) == "{{Param.Timeout}}" + + def test_simple_action_with_cancelation(self) -> None: + """Test SimpleAction with cancelation.""" + data = { + "script": "print('hello')", + "cancelation": {"mode": "NOTIFY_THEN_TERMINATE", "notifyPeriodInSeconds": 30}, + } + result = _parse_model(model=SimpleAction, obj=data, context=fb1_context()) + assert result.cancelation is not None + assert isinstance(result.cancelation, CancelationMethodNotifyThenTerminate) + assert result.cancelation.notifyPeriodInSeconds == 30 + + +class TestScriptInterpreterSyntaxSugar: + """Tests for script interpreter syntax sugar in StepTemplate.""" + + @pytest.mark.parametrize("interpreter", ["python", "bash", "cmd", "powershell", "node"]) + def test_interpreter_with_extension(self, interpreter: str) -> None: + """Test that interpreter syntax sugar works with FEATURE_BUNDLE_1.""" + data = {"name": "Step1", interpreter: {"script": "echo hello"}} + result = _parse_model(model=StepTemplate, obj=data, context=fb1_context()) + assert getattr(result, interpreter) is not None + assert result.script is None + + def test_python_interpreter_without_extension_fails(self) -> None: + """Test that python interpreter fails without extension.""" + data = {"name": "Step1", "python": {"script": "print('hello')"}} + with pytest.raises(ValidationError) as excinfo: + _parse_model(model=StepTemplate, obj=data, context=ModelParsingContext()) + assert "FEATURE_BUNDLE_1" in str(excinfo.value) + + def test_script_and_interpreter_fails(self) -> None: + """Test that specifying both script and interpreter fails.""" + data = {"name": "Step1", "script": STEP_SCRIPT, "python": {"script": "print('hello')"}} + with pytest.raises(ValidationError) as excinfo: + _parse_model(model=StepTemplate, obj=data, context=fb1_context()) + assert "Cannot specify both" in str(excinfo.value) + + def test_multiple_interpreters_fails(self) -> None: + """Test that specifying multiple interpreters fails.""" + data = { + "name": "Step1", + "python": {"script": "print('hello')"}, + "bash": {"script": "echo hello"}, + } + with pytest.raises(ValidationError) as excinfo: + _parse_model(model=StepTemplate, obj=data, context=fb1_context()) + assert "multiple" in str(excinfo.value).lower() + + def test_no_script_or_interpreter_fails(self) -> None: + """Test that missing both script and interpreter fails.""" + data = {"name": "Step1"} + with pytest.raises(ValidationError) as excinfo: + _parse_model(model=StepTemplate, obj=data, context=fb1_context()) + assert "Must specify" in str(excinfo.value) + + def test_interpreter_with_timeout(self) -> None: + """Test interpreter with timeout.""" + data = {"name": "Step1", "python": {"script": "print('hello')", "timeout": 60}} + result = _parse_model(model=StepTemplate, obj=data, context=fb1_context()) + assert result.python is not None + assert result.python.timeout == 60 + + def test_interpreter_with_args(self) -> None: + """Test interpreter with args.""" + data = {"name": "Step1", "python": {"script": "print('hello')", "args": ["-v"]}} + result = _parse_model(model=StepTemplate, obj=data, context=fb1_context()) + assert result.python is not None + assert result.python.args is not None + assert result.python.args[0] == "-v" + + +class TestJobTemplateWithFeatureBundle1: + """Integration tests for JobTemplate with FEATURE_BUNDLE_1 features.""" + + def test_full_template_with_all_features(self) -> None: + """Test a job template using multiple FEATURE_BUNDLE_1 features.""" + data = { + "specificationVersion": "jobtemplate-2023-09", + "extensions": ["FEATURE_BUNDLE_1"], + "name": "Test Job", + "parameterDefinitions": [ + {"name": "Timeout", "type": "INT", "default": 60}, + {"name": "CpuMin", "type": "INT", "default": 2}, + ], + "steps": [ + { + "name": "Step1", + "python": {"script": "print('hello')", "timeout": "{{Param.Timeout}}"}, + "hostRequirements": { + "amounts": [{"name": "amount.worker.vcpu", "min": "{{Param.CpuMin}}"}] + }, + } + ], + } + result = _parse_model(model=JobTemplate, obj=data, context=fb1_context()) + assert result.steps[0].python is not None + assert str(result.steps[0].python.timeout) == "{{Param.Timeout}}" + + def test_template_with_embedded_file_eol(self) -> None: + """Test job template with embedded file endOfLine.""" + data = { + "specificationVersion": "jobtemplate-2023-09", + "extensions": ["FEATURE_BUNDLE_1"], + "name": "Test Job", + "steps": [ + { + "name": "Step1", + "script": { + "actions": {"onRun": {"command": "bash", "args": ["{{Task.File.run}}"]}}, + "embeddedFiles": [ + { + "name": "run", + "type": "TEXT", + "data": "echo hello", + "endOfLine": "LF", + } + ], + }, + } + ], + } + result = _parse_model(model=JobTemplate, obj=data, context=fb1_context()) + assert result.steps[0].script is not None + assert result.steps[0].script.embeddedFiles is not None + assert result.steps[0].script.embeddedFiles[0].endOfLine is not None + assert result.steps[0].script.embeddedFiles[0].endOfLine.value == "LF" + + +class TestCreateJobWithFormatStrings: + """Tests for create_job with FEATURE_BUNDLE_1 format string resolution.""" + + def test_amount_requirement_min_max_resolved_valid(self) -> None: + """Test that resolved min/max values are validated correctly.""" + from openjd.model import create_job, decode_job_template + from openjd.model._types import ParameterValue, ParameterValueType + + template = decode_job_template( + template={ + "specificationVersion": "jobtemplate-2023-09", + "extensions": ["FEATURE_BUNDLE_1"], + "name": "Test", + "parameterDefinitions": [ + {"name": "CpuMin", "type": "INT", "default": 2}, + {"name": "CpuMax", "type": "INT", "default": 8}, + ], + "steps": [ + { + "name": "Step1", + "script": STEP_SCRIPT, + "hostRequirements": { + "amounts": [ + { + "name": "amount.worker.vcpu", + "min": "{{Param.CpuMin}}", + "max": "{{Param.CpuMax}}", + } + ] + }, + } + ], + }, + supported_extensions=[ExtensionName.FEATURE_BUNDLE_1], + ) + job = create_job( + job_template=template, + job_parameter_values={ + "CpuMin": ParameterValue(type=ParameterValueType.INT, value="2"), + "CpuMax": ParameterValue(type=ParameterValueType.INT, value="8"), + }, + ) + assert job.steps[0].hostRequirements is not None + assert job.steps[0].hostRequirements.amounts is not None + assert job.steps[0].hostRequirements.amounts[0].min == 2 + assert job.steps[0].hostRequirements.amounts[0].max == 8 + + def test_amount_requirement_min_greater_than_max_resolved_fails(self) -> None: + """Test that resolved min > max fails validation.""" + from openjd.model import create_job, decode_job_template + from openjd.model._errors import DecodeValidationError + from openjd.model._types import ParameterValue, ParameterValueType + + template = decode_job_template( + template={ + "specificationVersion": "jobtemplate-2023-09", + "extensions": ["FEATURE_BUNDLE_1"], + "name": "Test", + "parameterDefinitions": [ + {"name": "CpuMin", "type": "INT", "default": 2}, + {"name": "CpuMax", "type": "INT", "default": 8}, + ], + "steps": [ + { + "name": "Step1", + "script": STEP_SCRIPT, + "hostRequirements": { + "amounts": [ + { + "name": "amount.worker.vcpu", + "min": "{{Param.CpuMin}}", + "max": "{{Param.CpuMax}}", + } + ] + }, + } + ], + }, + supported_extensions=[ExtensionName.FEATURE_BUNDLE_1], + ) + with pytest.raises(DecodeValidationError) as excinfo: + create_job( + job_template=template, + job_parameter_values={ + "CpuMin": ParameterValue(type=ParameterValueType.INT, value="10"), + "CpuMax": ParameterValue(type=ParameterValueType.INT, value="5"), + }, + ) + assert "max" in str(excinfo.value).lower() + + +class TestAmountRequirementEdgeCases: + """Edge case tests for AmountRequirementTemplate.""" + + def test_min_negative_fails(self) -> None: + """Test that negative min value fails.""" + data = {"name": "amount.worker.vcpu", "min": -1} + with pytest.raises(ValidationError): + _parse_model(model=AmountRequirementTemplate, obj=data, context=fb1_context()) + + def test_max_zero_fails(self) -> None: + """Test that zero max value fails.""" + data = {"name": "amount.worker.vcpu", "max": 0} + with pytest.raises(ValidationError): + _parse_model(model=AmountRequirementTemplate, obj=data, context=fb1_context()) + + def test_min_greater_than_max_fails(self) -> None: + """Test that min > max fails when both are concrete values.""" + data = {"name": "amount.worker.vcpu", "min": 10, "max": 5} + with pytest.raises(ValidationError) as excinfo: + _parse_model(model=AmountRequirementTemplate, obj=data, context=fb1_context()) + assert "max" in str(excinfo.value).lower() + + def test_min_zero_succeeds(self) -> None: + """Test that min=0 succeeds.""" + data = {"name": "amount.worker.vcpu", "min": 0, "max": 1} + result = _parse_model(model=AmountRequirementTemplate, obj=data, context=fb1_context()) + assert result.min is not None + assert float(result.min) == 0 + + def test_min_max_equal_succeeds(self) -> None: + """Test that min=max succeeds.""" + data = {"name": "amount.worker.vcpu", "min": 5, "max": 5} + result = _parse_model(model=AmountRequirementTemplate, obj=data, context=fb1_context()) + assert result.min is not None + assert result.max is not None + assert float(result.min) == float(result.max) + + +class TestActionTimeoutEdgeCases: + """Edge case tests for Action timeout.""" + + def test_timeout_zero_fails(self) -> None: + """Test that timeout=0 fails.""" + data = {"command": "echo", "timeout": 0} + with pytest.raises(ValidationError): + _parse_model(model=Action, obj=data, context=fb1_context()) + + def test_timeout_negative_fails(self) -> None: + """Test that negative timeout fails.""" + data = {"command": "echo", "timeout": -1} + with pytest.raises(ValidationError): + _parse_model(model=Action, obj=data, context=fb1_context()) + + def test_timeout_float_string_fails(self) -> None: + """Test that float string timeout fails.""" + data = {"command": "echo", "timeout": "1.5"} + with pytest.raises(ValidationError): + _parse_model(model=Action, obj=data, context=fb1_context()) + + +class TestNotifyPeriodEdgeCases: + """Edge case tests for notifyPeriodInSeconds.""" + + def test_notify_period_zero_fails(self) -> None: + """Test that notifyPeriodInSeconds=0 fails.""" + data = {"mode": "NOTIFY_THEN_TERMINATE", "notifyPeriodInSeconds": 0} + with pytest.raises(ValidationError): + _parse_model( + model=CancelationMethodNotifyThenTerminate, obj=data, context=fb1_context() + ) + + def test_notify_period_over_600_fails(self) -> None: + """Test that notifyPeriodInSeconds > 600 fails.""" + data = {"mode": "NOTIFY_THEN_TERMINATE", "notifyPeriodInSeconds": 601} + with pytest.raises(ValidationError): + _parse_model( + model=CancelationMethodNotifyThenTerminate, obj=data, context=fb1_context() + ) + + def test_notify_period_600_succeeds(self) -> None: + """Test that notifyPeriodInSeconds=600 succeeds.""" + data = {"mode": "NOTIFY_THEN_TERMINATE", "notifyPeriodInSeconds": 600} + result = _parse_model( + model=CancelationMethodNotifyThenTerminate, obj=data, context=fb1_context() + ) + assert result.notifyPeriodInSeconds == 600 + + +class TestJobNameLength: + """Tests for JobName length validation (128 base, 512 with extension).""" + + def test_job_name_129_chars_without_extension_fails(self) -> None: + """Test that job name > 128 chars fails without extension.""" + data = { + "specificationVersion": "jobtemplate-2023-09", + "name": "J" * 129, + "steps": [{"name": "s", "script": STEP_SCRIPT}], + } + with pytest.raises(ValidationError) as excinfo: + _parse_model(model=JobTemplate, obj=data, context=ModelParsingContext()) + assert "128" in str(excinfo.value) + + def test_job_name_128_chars_without_extension_succeeds(self) -> None: + """Test that job name = 128 chars works without extension.""" + data = { + "specificationVersion": "jobtemplate-2023-09", + "name": "J" * 128, + "steps": [{"name": "s", "script": STEP_SCRIPT}], + } + result = _parse_model(model=JobTemplate, obj=data, context=ModelParsingContext()) + assert len(result.name) == 128 + + def test_job_name_512_chars_with_extension_succeeds(self) -> None: + """Test that job name = 512 chars works with extension.""" + data = { + "specificationVersion": "jobtemplate-2023-09", + "extensions": ["FEATURE_BUNDLE_1"], + "name": "J" * 512, + "steps": [{"name": "s", "script": STEP_SCRIPT}], + } + result = _parse_model(model=JobTemplate, obj=data, context=fb1_context()) + assert len(result.name) == 512 + + def test_job_name_513_chars_with_extension_fails(self) -> None: + """Test that job name > 512 chars fails even with extension.""" + data = { + "specificationVersion": "jobtemplate-2023-09", + "extensions": ["FEATURE_BUNDLE_1"], + "name": "J" * 513, + "steps": [{"name": "s", "script": STEP_SCRIPT}], + } + with pytest.raises(ValidationError) as excinfo: + _parse_model(model=JobTemplate, obj=data, context=fb1_context()) + assert "512" in str(excinfo.value) + + def test_job_name_format_string_longer_than_limit_succeeds(self) -> None: + """Test that format string longer than limit passes if resolved value is shorter.""" + from openjd.model import create_job, decode_job_template + from openjd.model._types import ParameterValue, ParameterValueType + + # Template name with format string is > 128 chars, but resolved is short + long_prefix = "J" * 120 + template = decode_job_template( + template={ + "specificationVersion": "jobtemplate-2023-09", + "name": f"{long_prefix}_{{{{Param.Name}}}}", # 135 chars unresolved + "parameterDefinitions": [{"name": "Name", "type": "STRING", "default": "X"}], + "steps": [{"name": "s", "script": STEP_SCRIPT}], + }, + ) + # Resolved name will be 120 + 1 + 4 = 125 chars, under 128 + job = create_job( + job_template=template, + job_parameter_values={ + "Name": ParameterValue(type=ParameterValueType.STRING, value="Test"), + }, + ) + assert job.name == f"{long_prefix}_Test" + assert len(job.name) == 125 + + +class TestStepNameLength: + """Tests for StepName length validation (64 base, 512 with extension).""" + + def test_step_name_65_chars_without_extension_fails(self) -> None: + """Test that step name > 64 chars fails without extension.""" + data = {"name": "S" * 65, "script": STEP_SCRIPT} + with pytest.raises(ValidationError) as excinfo: + _parse_model(model=StepTemplate, obj=data, context=ModelParsingContext()) + assert "64" in str(excinfo.value) + + def test_step_name_64_chars_without_extension_succeeds(self) -> None: + """Test that step name = 64 chars works without extension.""" + data = {"name": "S" * 64, "script": STEP_SCRIPT} + result = _parse_model(model=StepTemplate, obj=data, context=ModelParsingContext()) + assert len(result.name) == 64 + + def test_step_name_512_chars_with_extension_succeeds(self) -> None: + """Test that step name = 512 chars works with extension.""" + data = {"name": "S" * 512, "script": STEP_SCRIPT} + result = _parse_model(model=StepTemplate, obj=data, context=fb1_context()) + assert len(result.name) == 512 + + def test_step_name_513_chars_with_extension_fails(self) -> None: + """Test that step name > 512 chars fails even with extension.""" + data = {"name": "S" * 513, "script": STEP_SCRIPT} + with pytest.raises(ValidationError) as excinfo: + _parse_model(model=StepTemplate, obj=data, context=fb1_context()) + assert "512" in str(excinfo.value) + + +class TestEnvironmentNameLength: + """Tests for EnvironmentName length validation (64 base, 512 with extension).""" + + def test_env_name_65_chars_without_extension_fails(self) -> None: + """Test that environment name > 64 chars fails without extension.""" + from openjd.model.v2023_09 import Environment + + data = {"name": "E" * 65, "variables": {"FOO": "bar"}} + with pytest.raises(ValidationError) as excinfo: + _parse_model(model=Environment, obj=data, context=ModelParsingContext()) + assert "64" in str(excinfo.value) + + def test_env_name_64_chars_without_extension_succeeds(self) -> None: + """Test that environment name = 64 chars works without extension.""" + from openjd.model.v2023_09 import Environment + + data = {"name": "E" * 64, "variables": {"FOO": "bar"}} + result = _parse_model(model=Environment, obj=data, context=ModelParsingContext()) + assert len(result.name) == 64 + + def test_env_name_512_chars_with_extension_succeeds(self) -> None: + """Test that environment name = 512 chars works with extension.""" + from openjd.model.v2023_09 import Environment + + data = {"name": "E" * 512, "variables": {"FOO": "bar"}} + result = _parse_model(model=Environment, obj=data, context=fb1_context()) + assert len(result.name) == 512 + + def test_env_name_513_chars_with_extension_fails(self) -> None: + """Test that environment name > 512 chars fails even with extension.""" + from openjd.model.v2023_09 import Environment + + data = {"name": "E" * 513, "variables": {"FOO": "bar"}} + with pytest.raises(ValidationError) as excinfo: + _parse_model(model=Environment, obj=data, context=fb1_context()) + assert "512" in str(excinfo.value) + + +class TestIdentifierLength: + """Tests for Identifier length validation in EmbeddedFileText.name (64 base, 512 with extension).""" + + def test_identifier_65_chars_without_extension_fails(self) -> None: + """Test that identifier > 64 chars fails without extension.""" + data = {"name": "I" * 65, "type": "TEXT", "data": "echo"} + with pytest.raises(ValidationError) as excinfo: + _parse_model(model=EmbeddedFileText, obj=data, context=ModelParsingContext()) + assert "64" in str(excinfo.value) + + def test_identifier_64_chars_without_extension_succeeds(self) -> None: + """Test that identifier = 64 chars works without extension.""" + data = {"name": "I" * 64, "type": "TEXT", "data": "echo"} + result = _parse_model(model=EmbeddedFileText, obj=data, context=ModelParsingContext()) + assert len(result.name) == 64 + + def test_identifier_512_chars_with_extension_succeeds(self) -> None: + """Test that identifier = 512 chars works with extension.""" + data = {"name": "I" * 512, "type": "TEXT", "data": "echo"} + result = _parse_model(model=EmbeddedFileText, obj=data, context=fb1_context()) + assert len(result.name) == 512 + + def test_identifier_513_chars_with_extension_fails(self) -> None: + """Test that identifier > 512 chars fails even with extension.""" + data = {"name": "I" * 513, "type": "TEXT", "data": "echo"} + with pytest.raises(ValidationError) as excinfo: + _parse_model(model=EmbeddedFileText, obj=data, context=fb1_context()) + assert "512" in str(excinfo.value) + + +class TestResolveSyntaxSugar: + """Tests for StepTemplate.resolve_syntax_sugar() method.""" + + def test_returns_self_when_using_script(self) -> None: + """Test that resolve_syntax_sugar returns self when already using script.""" + data = {"name": "Step1", "script": STEP_SCRIPT} + template = _parse_model(model=StepTemplate, obj=data, context=ModelParsingContext()) + result = template.resolve_syntax_sugar() + assert result is template + + def test_python_desugaring(self) -> None: + """Test Python interpreter de-sugaring.""" + data = {"name": "Step1", "python": {"script": "print('hello')"}} + template = _parse_model(model=StepTemplate, obj=data, context=fb1_context()) + result = template.resolve_syntax_sugar() + + assert result is not template + assert result.script is not None + assert result.python is None + assert result.script.actions.onRun.command == "python" + assert result.script.embeddedFiles is not None + assert len(result.script.embeddedFiles) == 1 + # Filename should be based on step name with .py extension + assert result.script.embeddedFiles[0].filename is not None + assert result.script.embeddedFiles[0].filename.startswith("Step1_") + assert result.script.embeddedFiles[0].filename.endswith(".py") + assert "print" in str(result.script.embeddedFiles[0].data) + # Args should contain file reference + assert result.script.actions.onRun.args is not None + assert any("Task.File." in arg for arg in result.script.actions.onRun.args) + + def test_bash_desugaring(self) -> None: + """Test Bash interpreter de-sugaring.""" + data = {"name": "Step1", "bash": {"script": "echo hello"}} + template = _parse_model(model=StepTemplate, obj=data, context=fb1_context()) + result = template.resolve_syntax_sugar() + + assert result.script is not None + assert result.script.actions.onRun.command == "bash" + assert result.script.embeddedFiles is not None + assert result.script.embeddedFiles[0].filename is not None + assert result.script.embeddedFiles[0].filename.endswith(".sh") + + def test_cmd_desugaring_has_c_flag(self) -> None: + """Test cmd interpreter de-sugaring includes /C flag.""" + data = {"name": "Step1", "cmd": {"script": "echo hello"}} + template = _parse_model(model=StepTemplate, obj=data, context=fb1_context()) + result = template.resolve_syntax_sugar() + + assert result.script is not None + assert result.script.actions.onRun.command == "cmd" + assert result.script.actions.onRun.args is not None + assert result.script.actions.onRun.args[0] == "/C" + assert result.script.embeddedFiles is not None + assert result.script.embeddedFiles[0].filename is not None + assert result.script.embeddedFiles[0].filename.endswith(".bat") + + def test_powershell_desugaring_has_file_flag(self) -> None: + """Test PowerShell interpreter de-sugaring includes -File flag.""" + data = {"name": "Step1", "powershell": {"script": "Write-Host 'hello'"}} + template = _parse_model(model=StepTemplate, obj=data, context=fb1_context()) + result = template.resolve_syntax_sugar() + + assert result.script is not None + assert result.script.actions.onRun.command == "powershell" + assert result.script.actions.onRun.args is not None + assert result.script.actions.onRun.args[0] == "-File" + assert result.script.embeddedFiles is not None + assert result.script.embeddedFiles[0].filename is not None + assert result.script.embeddedFiles[0].filename.endswith(".ps1") + + def test_node_desugaring(self) -> None: + """Test Node.js interpreter de-sugaring.""" + data = {"name": "Step1", "node": {"script": "console.log('hello')"}} + template = _parse_model(model=StepTemplate, obj=data, context=fb1_context()) + result = template.resolve_syntax_sugar() + + assert result.script is not None + assert result.script.actions.onRun.command == "node" + assert result.script.embeddedFiles is not None + assert result.script.embeddedFiles[0].filename is not None + assert result.script.embeddedFiles[0].filename.endswith(".js") + + def test_preserves_user_args(self) -> None: + """Test that user-provided args are preserved after file reference.""" + data = {"name": "Step1", "python": {"script": "print('hello')", "args": ["-v", "--debug"]}} + template = _parse_model(model=StepTemplate, obj=data, context=fb1_context()) + result = template.resolve_syntax_sugar() + + assert result.script is not None + args = result.script.actions.onRun.args + assert args is not None + # Find the file reference arg + file_ref_idx = next(i for i, arg in enumerate(args) if "Task.File." in arg) + assert "-v" in args + assert "--debug" in args + # File ref should come before user args + assert args.index(ArgString("-v")) > file_ref_idx + + def test_special_characters_in_step_name(self) -> None: + """Test that special characters in step name are replaced with underscores.""" + data = {"name": "My Step-Name.v2", "python": {"script": "print('hello')"}} + template = _parse_model(model=StepTemplate, obj=data, context=fb1_context()) + result = template.resolve_syntax_sugar() + + assert result.script is not None + assert result.script.embeddedFiles is not None + filename = result.script.embeddedFiles[0].filename + assert filename is not None + # Should start with sanitized step name + assert filename.startswith("My_Step_Name_v2_") + assert filename.endswith(".py") + + def test_long_step_name_truncated(self) -> None: + """Test that long step names are truncated to fit filename limit.""" + long_name = "A" * 512 # Max step name with FEATURE_BUNDLE_1 + data = {"name": long_name, "python": {"script": "print('hello')"}} + template = _parse_model(model=StepTemplate, obj=data, context=fb1_context()) + result = template.resolve_syntax_sugar() + + assert result.script is not None + assert result.script.embeddedFiles is not None + filename = result.script.embeddedFiles[0].filename + assert filename is not None + # Filename must be <= 256 chars + assert len(filename) <= 256 + assert filename.endswith(".py") + + def test_unique_names_per_call(self) -> None: + """Test that each call generates a unique embedded file name.""" + data = {"name": "Step1", "python": {"script": "print('hello')"}} + template = _parse_model(model=StepTemplate, obj=data, context=fb1_context()) + + result1 = template.resolve_syntax_sugar() + result2 = template.resolve_syntax_sugar() + + assert result1.script is not None + assert result2.script is not None + name1 = result1.script.embeddedFiles[0].name # type: ignore + name2 = result2.script.embeddedFiles[0].name # type: ignore + # Names should be different due to random suffix + assert name1 != name2 + + def test_preserves_timeout(self) -> None: + """Test that timeout is preserved.""" + data = {"name": "Step1", "python": {"script": "print('hello')", "timeout": 60}} + template = _parse_model(model=StepTemplate, obj=data, context=fb1_context()) + result = template.resolve_syntax_sugar() + + assert result.script is not None + assert result.script.actions.onRun.timeout == 60 + + def test_preserves_timeout_format_string(self) -> None: + """Test that timeout format string is preserved.""" + data = {"name": "Step1", "python": {"script": "print('hello')", "timeout": "{{Param.T}}"}} + template = _parse_model(model=StepTemplate, obj=data, context=fb1_context()) + result = template.resolve_syntax_sugar() + + assert result.script is not None + assert str(result.script.actions.onRun.timeout) == "{{Param.T}}" + + def test_preserves_cancelation(self) -> None: + """Test that cancelation settings are preserved.""" + data = { + "name": "Step1", + "python": { + "script": "print('hello')", + "cancelation": {"mode": "NOTIFY_THEN_TERMINATE", "notifyPeriodInSeconds": 30}, + }, + } + template = _parse_model(model=StepTemplate, obj=data, context=fb1_context()) + result = template.resolve_syntax_sugar() + + assert result.script is not None + assert result.script.actions.onRun.cancelation is not None + assert isinstance( + result.script.actions.onRun.cancelation, CancelationMethodNotifyThenTerminate + ) + assert result.script.actions.onRun.cancelation.notifyPeriodInSeconds == 30 + + def test_preserves_step_metadata(self) -> None: + """Test that step metadata (name, description, etc.) is preserved.""" + + data = { + "name": "MyStep", + "description": "A test step", + "python": {"script": "print('hello')"}, + "stepEnvironments": [{"name": "Env1", "variables": {"FOO": "bar"}}], + } + template = _parse_model(model=StepTemplate, obj=data, context=fb1_context()) + result = template.resolve_syntax_sugar() + + assert result.name == "MyStep" + assert result.description == "A test step" + assert result.stepEnvironments is not None + assert len(result.stepEnvironments) == 1 + assert result.stepEnvironments[0].name == "Env1" + + def test_embedded_file_is_runnable(self) -> None: + """Test that the generated embedded file is marked as runnable.""" + data = {"name": "Step1", "python": {"script": "print('hello')"}} + template = _parse_model(model=StepTemplate, obj=data, context=fb1_context()) + result = template.resolve_syntax_sugar() + + assert result.script is not None + assert result.script.embeddedFiles is not None + assert result.script.embeddedFiles[0].runnable is True + + +class TestStepResolveSyntaxSugar: + """Tests for syntax sugar resolution during create_job.""" + + def _create_step(self, step_data: dict) -> Step: + """Helper: create JobTemplate with step, run create_job, return the Step.""" + template_data = { + "specificationVersion": "jobtemplate-2023-09", + "name": "TestJob", + "steps": [step_data], + "extensions": ["FEATURE_BUNDLE_1"], + } + template = _parse_model(model=JobTemplate, obj=template_data, context=fb1_context()) + job = create_job(job_template=template, job_parameter_values={}) + return job.steps[0] + + def test_script_preserved(self) -> None: + """Test that script is preserved when already using script.""" + step = self._create_step({"name": "Step1", "script": STEP_SCRIPT}) + assert step.script is not None + assert step.script.actions.onRun.command == "echo" + + def test_python_desugaring(self) -> None: + """Test Python interpreter de-sugaring.""" + step = self._create_step({"name": "Step1", "python": {"script": "print('hello')"}}) + + assert step.script is not None + assert step.script.actions.onRun.command == "python" + assert step.script.embeddedFiles is not None + assert len(step.script.embeddedFiles) == 1 + assert step.script.embeddedFiles[0].filename is not None + assert step.script.embeddedFiles[0].filename.endswith(".py") + assert step.script.actions.onRun.args is not None + assert any("Task.File." in arg for arg in step.script.actions.onRun.args) + + def test_bash_desugaring(self) -> None: + """Test Bash interpreter de-sugaring.""" + step = self._create_step({"name": "Step1", "bash": {"script": "echo hello"}}) + + assert step.script is not None + assert step.script.actions.onRun.command == "bash" + assert step.script.embeddedFiles is not None + assert step.script.embeddedFiles[0].filename is not None + assert step.script.embeddedFiles[0].filename.endswith(".sh") + + def test_cmd_desugaring_has_c_flag(self) -> None: + """Test cmd interpreter de-sugaring includes /C flag.""" + step = self._create_step({"name": "Step1", "cmd": {"script": "echo hello"}}) + + assert step.script is not None + assert step.script.actions.onRun.command == "cmd" + assert step.script.actions.onRun.args is not None + assert step.script.actions.onRun.args[0] == "/C" + assert step.script.embeddedFiles is not None + assert step.script.embeddedFiles[0].filename is not None + assert step.script.embeddedFiles[0].filename.endswith(".bat") + + def test_powershell_desugaring_has_file_flag(self) -> None: + """Test PowerShell interpreter de-sugaring includes -File flag.""" + step = self._create_step({"name": "Step1", "powershell": {"script": "Write-Host 'hello'"}}) + + assert step.script is not None + assert step.script.actions.onRun.command == "powershell" + assert step.script.actions.onRun.args is not None + assert step.script.actions.onRun.args[0] == "-File" + assert step.script.embeddedFiles is not None + assert step.script.embeddedFiles[0].filename is not None + assert step.script.embeddedFiles[0].filename.endswith(".ps1") + + def test_node_desugaring(self) -> None: + """Test Node.js interpreter de-sugaring.""" + step = self._create_step({"name": "Step1", "node": {"script": "console.log('hello')"}}) + + assert step.script is not None + assert step.script.actions.onRun.command == "node" + assert step.script.embeddedFiles is not None + assert step.script.embeddedFiles[0].filename is not None + assert step.script.embeddedFiles[0].filename.endswith(".js") + + def test_preserves_user_args(self) -> None: + """Test that user-provided args are preserved after file reference.""" + step = self._create_step( + {"name": "Step1", "python": {"script": "print('hello')", "args": ["-v"]}} + ) + + assert step.script is not None + args = step.script.actions.onRun.args + assert args is not None + file_ref_idx = next(i for i, arg in enumerate(args) if "Task.File." in arg) + assert "-v" in args + assert args.index(ArgString("-v")) > file_ref_idx + + def test_preserves_timeout(self) -> None: + """Test that timeout is preserved.""" + step = self._create_step( + {"name": "Step1", "python": {"script": "print('hello')", "timeout": 60}} + ) + + assert step.script is not None + assert step.script.actions.onRun.timeout == 60 diff --git a/test/openjd/model/v2023_09/test_strings.py b/test/openjd/model/v2023_09/test_strings.py index 6cabd95a..fda4f34e 100644 --- a/test/openjd/model/v2023_09/test_strings.py +++ b/test/openjd/model/v2023_09/test_strings.py @@ -150,7 +150,7 @@ def test_parse_success(self, value: str) -> None: ( pytest.param({"name": 12}, id="not string"), pytest.param({"name": ""}, id="too short"), - pytest.param({"name": "a" * 129}, id="too long"), + pytest.param({"name": "a" * 513}, id="too long"), pytest.param({"name": "\n"}, id="no newline"), # Just testing the boundary points of the allowable characters pytest.param({"name": "\u0000"}, id="NULL disallowed"), @@ -194,7 +194,7 @@ def test_parse_success(self, value: str) -> None: ( pytest.param({"name": 12}, id="not string"), pytest.param({"name": ""}, id="too short"), - pytest.param({"name": "a" * 65}, id="too long"), + pytest.param({"name": "a" * 513}, id="too long"), pytest.param({"name": "\n"}, id="no newline"), # Just testing the boundary points of the allowable characters pytest.param({"name": "\u0000"}, id="NULL disallowed"), @@ -238,7 +238,7 @@ def test_parse_success(self, value: str) -> None: ( pytest.param({"name": 12}, id="not string"), pytest.param({"name": ""}, id="too short"), - pytest.param({"name": "a" * 65}, id="too long"), + pytest.param({"name": "a" * 513}, id="too long"), pytest.param({"name": "\n"}, id="no newline"), # Just testing the boundary points of the allowable characters pytest.param({"name": "\u0000"}, id="NULL disallowed"), @@ -390,7 +390,7 @@ def test_parse_success(self, value: str) -> None: [ pytest.param({"id": 12}, id="not string"), pytest.param({"id": ""}, id="too short"), - pytest.param({"id": "a" * 65}, id="too long"), + pytest.param({"id": "a" * 513}, id="too long"), pytest.param({"id": " a"}, id="start space"), pytest.param({"id": "a "}, id="end space"), pytest.param({"id": "0"}, id="no digit start(0)"),