Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion src/openjd/model/v2023_09/_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -1828,6 +1828,13 @@ class StepParameterSpaceDefinition(OpenJDModel_v2023_09):
reshape_field_to_dict={"taskParameterDefinitions": "name"},
)

# §2 makes task parameter type names case-insensitive under EXPR, the same as
# job parameter type names. Must run before discriminated-union resolution.
@field_validator("taskParameterDefinitions", mode="before")
@classmethod
def _normalize_parameter_type_case(cls, v: Any, info: ValidationInfo) -> Any:
return _normalize_parameter_type_case(v, info)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just applies the validator below to support mixed case.


@field_validator("taskParameterDefinitions")
@classmethod
def _validate_parameters(cls, v: TaskParameterList) -> TaskParameterList:
Expand Down Expand Up @@ -3967,11 +3974,18 @@ def _expr_param_gate(value: JobParameterType, info: ValidationInfo) -> JobParame
return value


# Parameter type names are ASCII (§2). str.upper() is Unicode-aware and folds
# U+0131 to 'I', which would make 'ıNT' a spelling of 'INT'.
_ASCII_UPPERCASE = str.maketrans("abcdefghijklmnopqrstuvwxyz", "ABCDEFGHIJKLMNOPQRSTUVWXYZ")


def _normalize_parameter_type_case(value: Any, info: ValidationInfo) -> Any:
"""Uppercase the ``type`` discriminator of each parameter definition when
the EXPR extension is enabled (RFC 0007 makes parameter type names
case-insensitive, e.g. ``int`` == ``INT``, ``list[int]`` == ``LIST[INT]``).
Runs before discriminated-union resolution.

Applies to job parameter and task parameter type names alike, per §2.
"""
context = cast(Optional[ModelParsingContext], info.context)
if not (context and "EXPR" in context.extensions):
Expand All @@ -3981,7 +3995,7 @@ def _normalize_parameter_type_case(value: Any, info: ValidationInfo) -> Any:
normalized: list[Any] = []
for item in value:
if isinstance(item, dict) and isinstance(item.get("type"), str):
item = {**item, "type": item["type"].upper()}
item = {**item, "type": item["type"].translate(_ASCII_UPPERCASE)}
normalized.append(item)
return normalized

Expand Down
70 changes: 70 additions & 0 deletions test/openjd/model_v0/v2023_09/test_environment_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import pytest
from pydantic import ValidationError

from openjd.model import DecodeValidationError, decode_environment_template
from openjd.model._parse import _parse_model
from openjd.model.v2023_09 import EnvironmentTemplate

Expand Down Expand Up @@ -256,3 +257,72 @@ def test_parse_fails(self, data: dict[str, Any], expected_num_errors: int) -> No

# THEN
assert len(excinfo.value.errors()) == expected_num_errors


class TestEnvironmentTemplateParameterTypeNameCase:
"""Template Schemas §2: job parameter type names are case-sensitive in base
2023-09 and case-insensitive when the EXPR extension is enabled. An
environment template's ``parameterDefinitions`` carries the same
``JobParameterDefinition`` union as a job template's, so the same rule applies.

``EnvironmentTemplate`` is the third registration site of the shared fold, and
it is the one an audit found unpinned: neutering it left the whole model_v0
suite green. The job template counterpart is
``test_list_parameters.py::TestJobParameterTypeNameCase`` and the task
parameter one is ``test_parameter_space.py::TestTaskParameterTypeNameCase``.
"""

@staticmethod
def _tmpl(type_name: str, *, extensions: tuple[str, ...] = ("EXPR",)) -> dict[str, Any]:
template: dict[str, Any] = {
"specificationVersion": "environment-2023-09",
"parameterDefinitions": [{"name": "P", "type": type_name}],
"environment": ENVIRONMENT,
}
if extensions:
template["extensions"] = list(extensions)
return template

@staticmethod
def _decode(template: dict[str, Any]) -> None:
decode_environment_template(template=template, supported_extensions=["EXPR"])

# (canonical spelling, a mis-cased spelling)
TYPES: tuple = (
pytest.param("STRING", "string", id="string"),
pytest.param("INT", "iNt", id="int"),
pytest.param("PATH", "pAtH", id="path"),
)

@pytest.mark.parametrize("canonical, miscased", TYPES)
def test_no_expr_canonical_case_accepted(self, canonical: str, miscased: str) -> None:
# Case 1 of 4.
self._decode(self._tmpl(canonical, extensions=()))

@pytest.mark.parametrize("canonical, miscased", TYPES)
def test_no_expr_miscased_rejected(self, canonical: str, miscased: str) -> None:
# Case 2 of 4. Fails if the fold is registered without its EXPR gate.
with pytest.raises(DecodeValidationError) as excinfo:
self._decode(self._tmpl(miscased, extensions=()))
message = str(excinfo.value)
assert "parameterDefinitions[0]" in message, message
assert f"'{miscased}'" in message, message

@pytest.mark.parametrize("canonical, miscased", TYPES)
def test_with_expr_canonical_case_accepted(self, canonical: str, miscased: str) -> None:
# Case 3 of 4.
self._decode(self._tmpl(canonical))

@pytest.mark.parametrize("canonical, miscased", TYPES)
def test_with_expr_miscased_accepted(self, canonical: str, miscased: str) -> None:
# Case 4 of 4. Fails if the fold is not registered on this model at all,
# which is the state an audit found untested.
self._decode(self._tmpl(miscased))

@pytest.mark.parametrize("type_name", ("\u0131NT", "\u017fTRING", "\ufb02OAT"))
def test_non_ascii_lookalike_rejected_with_expr(self, type_name: str) -> None:
# str.upper() folds U+0131 to 'I', U+017F to 'S' and U+FB02 (fl) to 'FL',
# so a Unicode-aware fold would read these as INT, STRING and FLOAT.
with pytest.raises(DecodeValidationError) as excinfo:
self._decode(self._tmpl(type_name))
assert f"'{type_name}'" in str(excinfo.value), str(excinfo.value)
79 changes: 79 additions & 0 deletions test/openjd/model_v0/v2023_09/test_list_parameters.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,82 @@ def test_requires_expr(self):
def test_rejects(self, param):
with pytest.raises(DecodeValidationError):
_decode(_tmpl(param))


class TestJobParameterTypeNameCase:
"""Template Schemas §2: job parameter type names are case-sensitive in base
2023-09 and case-insensitive when the EXPR extension is enabled.

``TestListValid.test_case_insensitive_type`` covers one of the four
extension-by-spelling combinations. This covers all four, on both an
EXPR-only type and a base type, and pins that the shared fold is ASCII.

The task parameter counterpart is
``test_parameter_space.py::TestTaskParameterTypeNameCase``.
"""

# (canonical spelling, a mis-cased spelling, a valid default)
TYPES: tuple = (
("STRING", "string", "a"), # base type, available without EXPR
("INT", "iNt", 1), # base type
("LIST[INT]", "list[int]", [1, 2]), # EXPR-only type
("LIST[LIST[INT]]", "List[List[Int]]", [[1], [2]]), # EXPR-only, nested brackets
)

@staticmethod
def _param(type_name, default):
return {"name": "P", "type": type_name, "default": default}

@pytest.mark.parametrize("canonical, miscased, default", TYPES)
def test_no_expr_canonical_case(self, canonical, miscased, default):
# Case 1 of 4. Without EXPR the spec spelling is the only one accepted.
# A base type is accepted; an EXPR-only type is rejected for needing EXPR,
# which is a different rejection from the casing one in case 2.
template = _tmpl(self._param(canonical, default), extensions=())
if canonical.startswith("LIST["):
with pytest.raises(DecodeValidationError, match="requires the EXPR extension"):
_decode(template)
else:
_decode(template)

@pytest.mark.parametrize("canonical, miscased, default", TYPES)
def test_no_expr_miscased_rejected(self, canonical, miscased, default):
# Case 2 of 4. Without EXPR, type names are case-sensitive.
with pytest.raises(DecodeValidationError) as excinfo:
_decode(_tmpl(self._param(miscased, default), extensions=()))
message = str(excinfo.value)
assert "parameterDefinitions[0]" in message, message
assert f"'{miscased}'" in message, message
# Rejected for the casing, not for the extension. An EXPR-only type spelled
# correctly would say "requires the EXPR extension" instead.
assert "requires the EXPR extension" not in message, message

@pytest.mark.parametrize("canonical, miscased, default", TYPES)
def test_with_expr_canonical_case_accepted(self, canonical, miscased, default):
# Case 3 of 4. Enabling EXPR must not break the spec spelling.
_decode(_tmpl(self._param(canonical, default)))

@pytest.mark.parametrize("canonical, miscased, default", TYPES)
def test_with_expr_miscased_accepted(self, canonical, miscased, default):
# Case 4 of 4.
_decode(_tmpl(self._param(miscased, default)))

# ── The fold is ASCII ──

# str.upper() folds each of these wholly into the type-name alphabet: U+0131
# dotless i to 'I', U+017F long s to 'S', U+FB02 ligature fl to 'FL', U+FB06
# ligature st to 'ST'. A Unicode-aware fold reads them as real type names.
LOOKALIKES = ("\u0131NT", "\u017fTRING", "\ufb02OAT", "\ufb06RING")

@pytest.mark.parametrize("type_name", LOOKALIKES)
def test_non_ascii_lookalike_rejected_with_expr(self, type_name):
with pytest.raises(DecodeValidationError) as excinfo:
_decode(_tmpl(self._param(type_name, None)))
assert f"'{type_name}'" in str(excinfo.value), str(excinfo.value)

@pytest.mark.parametrize("type_name", LOOKALIKES)
def test_non_ascii_lookalike_rejected_without_expr(self, type_name):
# Inert against this change by design: without EXPR no fold runs. Present
# so the pair covers both extension states.
with pytest.raises(DecodeValidationError):
_decode(_tmpl(self._param(type_name, None), extensions=()))
Loading
Loading