diff --git a/src/openjd/model/v2023_09/_model.py b/src/openjd/model/v2023_09/_model.py index 56caa30c..afe189be 100644 --- a/src/openjd/model/v2023_09/_model.py +++ b/src/openjd/model/v2023_09/_model.py @@ -921,6 +921,11 @@ class ScriptInterpreter(str, Enum): LET_MAX_BINDINGS = 50 _LET_NAME_RE = re.compile(r"^[a-z_][A-Za-z0-9_]*$") +# §3.6.1: maximum length of a `let` binding's ``. Flat, so not +# the §7.1 cap NameIdentifierLengthMixin applies: that one is 64 without +# FEATURE_BUNDLE_1, and a 512-character name must be accepted with EXPR alone. +LET_MAX_IDENTIFIER_LEN = 512 + def parse_let_bindings(value: Any) -> list[tuple[str, str]]: """Parse a ``let`` field value (list of ``"name = expression"`` strings) @@ -941,6 +946,14 @@ def parse_let_bindings(value: Any) -> list[tuple[str, str]]: expr = expr.strip() if not _LET_NAME_RE.match(name): raise ValueError(f"A 'let' binding name must be a valid identifier: {name!r}") + # Truncated rather than omitted: the caller is a field_validator on the + # whole list, so the error path is `let` with no index to identify which + # binding is over. + if len(name) > LET_MAX_IDENTIFIER_LEN: + raise ValueError( + f"A 'let' binding name must be at most {LET_MAX_IDENTIFIER_LEN} " + f"characters long: {name[:32]!r}... ({len(name)} characters)" + ) if not expr: raise ValueError(f"A 'let' binding must define an expression: {binding!r}") result.append((name, expr)) diff --git a/test/openjd/model_v0/v2023_09/test_let_bindings.py b/test/openjd/model_v0/v2023_09/test_let_bindings.py index ac0ddc6a..64171b48 100644 --- a/test/openjd/model_v0/v2023_09/test_let_bindings.py +++ b/test/openjd/model_v0/v2023_09/test_let_bindings.py @@ -41,6 +41,29 @@ def test_script_let(self): _job([{"name": "S", "script": {"let": ["a = 2"], **_onrun("{{a}}")}}]), ) + # §3.6.1 boundary: 512 characters is the maximum and must be accepted, with + # EXPR alone, since the cap does not depend on FEATURE_BUNDLE_1. + def test_name_512_chars(self): + name = "a" * 512 + # Referenced, not just declared: a cap further down the path would + # otherwise be invisible here. + _decode(_job([{"name": "S", "let": [f"{name} = 1"], "script": _onrun(f"{{{{{name}}}}}")}])) + + def test_name_512_chars_with_fb1(self): + name = "a" * 512 + _decode( + _job( + [{"name": "S", "let": [f"{name} = 1"], "script": _onrun(f"{{{{{name}}}}}")}], + extensions=("EXPR", "FEATURE_BUNDLE_1"), + ) + ) + + def test_name_512_chars_script(self): + name = "a" * 512 + _decode( + _job([{"name": "S", "script": {"let": [f"{name} = 1"], **_onrun(f"{{{{{name}}}}}")}}]) + ) + def test_chained_and_functions(self): _decode( _job( @@ -98,6 +121,40 @@ def test_self_reference(self): with pytest.raises(DecodeValidationError, match="cannot reference itself"): _decode(_job([{"name": "S", "let": ["x = x + 1"], "script": _onrun("hi")}])) + # §3.6.1 caps a `` at 512 characters. `_job` declares EXPR + # alone, so these pin the cap independently of FEATURE_BUNDLE_1. + def test_name_513_chars(self): + name = "a" * 513 + with pytest.raises( + DecodeValidationError, + match=r"at most 512 characters long: 'a{32}'\.\.\. \(513 characters\)", + ): + _decode(_job([{"name": "S", "let": [f"{name} = 1"], "script": _onrun("hi")}])) + + def test_name_513_chars_names_the_offending_binding(self): + # The validator is a field_validator on the whole list, so the error path + # is `let` with no index; the message has to identify the binding itself. + name = "b" * 513 + with pytest.raises(DecodeValidationError, match=r"'b{32}'\.\.\. \(513 characters\)"): + _decode(_job([{"name": "S", "let": ["ok = 1", f"{name} = 2"], "script": _onrun("hi")}])) + + def test_name_513_chars_with_fb1(self): + name = "a" * 513 + with pytest.raises(DecodeValidationError, match="at most 512 characters"): + _decode( + _job( + [{"name": "S", "let": [f"{name} = 1"], "script": _onrun("hi")}], + extensions=("EXPR", "FEATURE_BUNDLE_1"), + ) + ) + + def test_name_513_chars_script(self): + name = "a" * 513 + with pytest.raises(DecodeValidationError, match="at most 512 characters"): + _decode( + _job([{"name": "S", "script": {"let": [f"{name} = 1"], **_onrun("hi")}}]), + ) + def test_comprehension_shadows_let(self): with pytest.raises(DecodeValidationError, match="shadows"): _decode(