diff --git a/src/openjd/model/v2023_09/_model.py b/src/openjd/model/v2023_09/_model.py index f0aa144..b0a8837 100644 --- a/src/openjd/model/v2023_09/_model.py +++ b/src/openjd/model/v2023_09/_model.py @@ -56,6 +56,11 @@ TemplateVariableDef, ) +# Error message constants +_ALLOWED_VALUES_NONE_ERROR = "allowedValues cannot be None. The field must contain at least one value or be omitted entirely." +_VALUE_LESS_THAN_MIN_ERROR = "Value less than minValue." +_VALUE_LARGER_THAN_MAX_ERROR = "Value larger than maxValue." + class ModelParsingContext(ModelParsingContextInterface): """Context required while parsing an OpenJDModel. An instance of this class @@ -1211,6 +1216,8 @@ def _validate_max_length(cls, value: Optional[int], info: ValidationInfo) -> Opt def _validate_allowed_values_item( cls, value: AllowedParameterStringValueList, info: ValidationInfo ) -> AllowedParameterStringValueList: + if value is None: + raise ValueError(_ALLOWED_VALUES_NONE_ERROR) min_length = info.data.get("minLength") max_length = info.data.get("maxLength") errors = list[InitErrorDetails]() @@ -1421,7 +1428,7 @@ class JobPathParameterDefinition(OpenJDModel_v2023_09, JobParameterInterface): "default", }, adds_fields=lambda this, symtab: { - "value": symtab[f"RawParam.{cast(JobStringParameterDefinition,this).name}"] + "value": symtab[f"RawParam.{cast(JobPathParameterDefinition,this).name}"] }, ) @@ -1451,8 +1458,10 @@ def _validate_max_length(cls, value: Optional[int], info: ValidationInfo) -> Opt @field_validator("allowedValues") @classmethod def _validate_allowed_values_item( - cls, value: ParameterStringValue, info: ValidationInfo - ) -> ParameterStringValue: + cls, value: AllowedParameterStringValueList, info: ValidationInfo + ) -> AllowedParameterStringValueList: + if value is None: + raise ValueError(_ALLOWED_VALUES_NONE_ERROR) min_length = info.data.get("minLength") max_length = info.data.get("maxLength") errors = list[InitErrorDetails]() @@ -1462,7 +1471,7 @@ def _validate_allowed_values_item( errors.append( InitErrorDetails( type="value_error", - loc=("allowedValues", i), + loc=(i,), ctx={"error": ValueError("Value is shorter than minLength.")}, input=item, ) @@ -1472,7 +1481,7 @@ def _validate_allowed_values_item( errors.append( InitErrorDetails( type="value_error", - loc=("allowedValues", i), + loc=(i,), ctx={"error": ValueError("Value is longer than maxLength.")}, input=item, ) @@ -1610,7 +1619,7 @@ class JobIntParameterDefinition(OpenJDModel_v2023_09): allowedValues (Optional[AllowedIntParameterList]): Explicit list of values that the parameter is allowed to take on. minValue (Optional[int]): Minimum value that the parameter is allowed to be. - maxValue (Optional[int]): Minimum value that the parameter is allowed to be. + maxValue (Optional[int]): Maximum value that the parameter is allowed to be. """ name: Identifier @@ -1671,7 +1680,12 @@ def _validate_max_value_type(cls, value: Optional[Any]) -> Optional[Any]: @field_validator("allowedValues", mode="before") @classmethod - def _validate_allowed_values_item_type(cls, value: Any) -> Any: + def _validate_allowed_values_item_type( + cls, value: AllowedIntParameterList + ) -> AllowedIntParameterList: + if value is None: + raise ValueError(_ALLOWED_VALUES_NONE_ERROR) + errors = list[InitErrorDetails]() for i, item in enumerate(value): if isinstance(item, bool) or not isinstance(item, (int, str)): @@ -1712,7 +1726,11 @@ def _validate_max_value(cls, value: Optional[int], info: ValidationInfo) -> Opti @field_validator("allowedValues") @classmethod - def _validate_allowed_values_item(cls, value: list[int], info: ValidationInfo) -> list[int]: + def _validate_allowed_values_item( + cls, value: AllowedIntParameterList, info: ValidationInfo + ) -> AllowedIntParameterList: + if value is None: + raise ValueError(_ALLOWED_VALUES_NONE_ERROR) min_value = info.data.get("minValue") max_value = info.data.get("maxValue") errors = list[InitErrorDetails]() @@ -1723,7 +1741,7 @@ def _validate_allowed_values_item(cls, value: list[int], info: ValidationInfo) - InitErrorDetails( type="value_error", loc=(i,), - ctx={"error": ValueError("Value less than minValue.")}, + ctx={"error": ValueError(_VALUE_LESS_THAN_MIN_ERROR)}, input=item, ) ) @@ -1733,7 +1751,7 @@ def _validate_allowed_values_item(cls, value: list[int], info: ValidationInfo) - InitErrorDetails( type="value_error", loc=(i,), - ctx={"error": ValueError("Value larger than minValue.")}, + ctx={"error": ValueError(_VALUE_LARGER_THAN_MAX_ERROR)}, input=item, ) ) @@ -1747,11 +1765,11 @@ def _validate_default(cls, value: int, info: ValidationInfo) -> int: min_value = info.data.get("minValue") if min_value is not None: if value < min_value: - raise ValueError("Value less than minValue.") + raise ValueError(_VALUE_LESS_THAN_MIN_ERROR) max_value = info.data.get("maxValue") if max_value is not None: if value > max_value: - raise ValueError("Value larger than maxValue.") + raise ValueError(_VALUE_LARGER_THAN_MAX_ERROR) allowed_values = info.data.get("allowedValues") if allowed_values is not None: @@ -1856,7 +1874,7 @@ class JobFloatParameterDefinition(OpenJDModel_v2023_09): allowedValues (Optional[AllowedFloatParameterList]): Explicit list of values that the parameter is allowed to take on. minValue (Optional[Decimal]): Minimum value that the parameter is allowed to be. - maxValue (Optional[Decimal]): Minimum value that the parameter is allowed to be. + maxValue (Optional[Decimal]): Maximum value that the parameter is allowed to be. """ name: Identifier @@ -1909,8 +1927,10 @@ def _validate_max_value( @field_validator("allowedValues") @classmethod def _validate_allowed_values_item( - cls, value: list[Decimal], info: ValidationInfo - ) -> list[Decimal]: + cls, value: AllowedFloatParameterList, info: ValidationInfo + ) -> AllowedFloatParameterList: + if value is None: + raise ValueError(_ALLOWED_VALUES_NONE_ERROR) min_value = info.data.get("minValue") max_value = info.data.get("maxValue") errors = list[InitErrorDetails]() @@ -1921,7 +1941,7 @@ def _validate_allowed_values_item( InitErrorDetails( type="value_error", loc=(i,), - ctx={"error": ValueError("Value less than minValue.")}, + ctx={"error": ValueError(_VALUE_LESS_THAN_MIN_ERROR)}, input=item, ) ) @@ -1931,7 +1951,7 @@ def _validate_allowed_values_item( InitErrorDetails( type="value_error", loc=(i,), - ctx={"error": ValueError("Value larger than maxValue.")}, + ctx={"error": ValueError(_VALUE_LARGER_THAN_MAX_ERROR)}, input=item, ) ) @@ -1945,11 +1965,11 @@ def _validate_default(cls, value: Decimal, info: ValidationInfo) -> Decimal: min_value = info.data.get("minValue") if min_value is not None: if value < min_value: - raise ValueError("Value less than minValue.") + raise ValueError(_VALUE_LESS_THAN_MIN_ERROR) max_value = info.data.get("maxValue") if max_value is not None: if value > max_value: - raise ValueError("Value larger than maxValue.") + raise ValueError(_VALUE_LARGER_THAN_MAX_ERROR) allowed_values = info.data.get("allowedValues") if allowed_values is not None: diff --git a/test/openjd/model/v2023_09/test_job_parameters.py b/test/openjd/model/v2023_09/test_job_parameters.py index 4759f59..cc8bf96 100644 --- a/test/openjd/model/v2023_09/test_job_parameters.py +++ b/test/openjd/model/v2023_09/test_job_parameters.py @@ -152,6 +152,13 @@ class TestJobStringParameterDefinition: }, id="all fields", ), + pytest.param( + { + "name": "Foo", + "type": "STRING", + }, + id="allowedValues not provided", + ), ), ) def test_parse_success(self, data: dict[str, Any]) -> None: @@ -195,6 +202,14 @@ def test_parse_success(self, data: dict[str, Any]) -> None: pytest.param( {"name": "Foo", "type": "STRING", "allowedValues": []}, id="allowedValues too small" ), + pytest.param( + { + "name": "Foo", + "type": "STRING", + "allowedValues": None, + }, + id="allowedValues is explicitly None", + ), pytest.param( {"name": "Foo", "type": "STRING", "allowedValues": [12]}, id="allowedValues item not string", @@ -316,6 +331,54 @@ def test_parse_fails(self, data: dict[str, Any]) -> None: # THEN assert len(excinfo.value.errors()) > 0 + def test_allowedvalues_minlength_error_location(self) -> None: + # Test that error location reporting includes the field name and index for minLength validation + data = { + "name": "Foo", + "type": "STRING", + "minLength": 10, # Make this larger to ensure validation fails + "allowedValues": ["short", "short", "long_enough_value"], + } + + # WHEN + with pytest.raises(ValidationError) as excinfo: + _parse_model(model=JobStringParameterDefinition, obj=data) + + # THEN + errors = excinfo.value.errors() + assert len(errors) > 0 + # Check that the error location includes both "allowedValues" and the index + assert any(error["loc"] == ("allowedValues", 0) for error in errors) + assert any(error["loc"] == ("allowedValues", 1) for error in errors) + # Verify that the third value doesn't trigger an error (it's long enough) + assert not any(error["loc"] == ("allowedValues", 2) for error in errors) + # Check the error message + assert "Value is shorter than minLength" in str(excinfo.value) + + def test_allowedvalues_maxlength_error_location(self) -> None: + # Test that error location reporting includes the field name and index for maxLength validation + data = { + "name": "Foo", + "type": "STRING", + "maxLength": 2, # Make this smaller to ensure validation fails + "allowedValues": ["ok", "ok", "too_long_value"], + } + + # WHEN + with pytest.raises(ValidationError) as excinfo: + _parse_model(model=JobStringParameterDefinition, obj=data) + + # THEN + errors = excinfo.value.errors() + assert len(errors) > 0 + # Check that the error location includes both "allowedValues" and the index + # Only the third value should fail (it's too long) + assert not any(error["loc"] == ("allowedValues", 0) for error in errors) + assert not any(error["loc"] == ("allowedValues", 1) for error in errors) + assert any(error["loc"] == ("allowedValues", 2) for error in errors) + # Check the error message + assert "Value is longer than maxLength" in str(excinfo.value) + @pytest.mark.parametrize( "value,parameter", [ @@ -591,6 +654,13 @@ class TestJobPathParameterDefinition: }, id="all fields", ), + pytest.param( + { + "name": "Foo", + "type": "PATH", + }, + id="allowedValues not provided", + ), ), ) def test_parse_success(self, data: dict[str, Any]) -> None: @@ -626,6 +696,14 @@ def test_parse_success(self, data: dict[str, Any]) -> None: pytest.param( {"name": "Foo", "type": "PATH", "allowedValues": []}, id="allowedValues too small" ), + pytest.param( + { + "name": "Foo", + "type": "PATH", + "allowedValues": None, + }, + id="allowedValues is explicitly None", + ), pytest.param( {"name": "Foo", "type": "PATH", "allowedValues": [12]}, id="allowedValues item not string", @@ -908,6 +986,54 @@ def test_parse_fails(self, data: dict[str, Any]) -> None: # THEN assert len(excinfo.value.errors()) > 0 + def test_allowedvalues_minlength_error_location(self) -> None: + # Test that error location reporting includes the field name and index for minLength validation + data = { + "name": "Foo", + "type": "PATH", + "minLength": 10, # Make this larger to ensure validation fails + "allowedValues": ["short", "short", "long_enough_value"], + } + + # WHEN + with pytest.raises(ValidationError) as excinfo: + _parse_model(model=JobPathParameterDefinition, obj=data) + + # THEN + errors = excinfo.value.errors() + assert len(errors) > 0 + # Check that the error location includes both "allowedValues" and the index + assert any(error["loc"] == ("allowedValues", 0) for error in errors) + assert any(error["loc"] == ("allowedValues", 1) for error in errors) + # Verify that the third value doesn't trigger an error (it's long enough) + assert not any(error["loc"] == ("allowedValues", 2) for error in errors) + # Check the error message + assert "Value is shorter than minLength" in str(excinfo.value) + + def test_allowedvalues_maxlength_error_location(self) -> None: + # Test that error location reporting includes the field name and index for maxLength validation + data = { + "name": "Foo", + "type": "PATH", + "maxLength": 2, # Make this smaller to ensure validation fails + "allowedValues": ["ok", "ok", "too_long_value"], + } + + # WHEN + with pytest.raises(ValidationError) as excinfo: + _parse_model(model=JobPathParameterDefinition, obj=data) + + # THEN + errors = excinfo.value.errors() + assert len(errors) > 0 + # Check that the error location includes both "allowedValues" and the index + # Only the third value should fail (it's too long) + assert not any(error["loc"] == ("allowedValues", 0) for error in errors) + assert not any(error["loc"] == ("allowedValues", 1) for error in errors) + assert any(error["loc"] == ("allowedValues", 2) for error in errors) + # Check the error message + assert "Value is longer than maxLength" in str(excinfo.value) + @pytest.mark.parametrize( "value,parameter", [ @@ -1105,6 +1231,13 @@ class TestJobIntParameterDefinition: }, id="all fields", ), + pytest.param( + { + "name": "Foo", + "type": "INT", + }, + id="allowedValues not provided", + ), ), ) def test_parse_success(self, data: dict[str, Any]) -> None: @@ -1136,6 +1269,14 @@ def test_parse_success(self, data: dict[str, Any]) -> None: pytest.param( {"name": "Foo", "type": "INT", "allowedValues": []}, id="allowedValues too small" ), + pytest.param( + { + "name": "Foo", + "type": "INT", + "allowedValues": None, + }, + id="allowedValues is explicitly None", + ), pytest.param( {"name": "Foo", "type": "INT", "allowedValues": ["aa"]}, id="allowedValues item not number", @@ -1275,6 +1416,54 @@ def test_parse_fails(self, data: dict[str, Any]) -> None: # THEN assert len(excinfo.value.errors()) > 0 + def test_allowedvalues_minvalue_error_location_int(self) -> None: + # Test that error location reporting includes the field name and index for minValue validation + data = { + "name": "Foo", + "type": "INT", + "minValue": 10, # Make this larger to ensure validation fails + "allowedValues": [5, 6, 15], + } + + # WHEN + with pytest.raises(ValidationError) as excinfo: + _parse_model(model=JobIntParameterDefinition, obj=data) + + # THEN + errors = excinfo.value.errors() + assert len(errors) > 0 + # Check that the error location includes both "allowedValues" and the index + assert any(error["loc"] == ("allowedValues", 0) for error in errors) + assert any(error["loc"] == ("allowedValues", 1) for error in errors) + # Verify that the third value doesn't trigger an error (it's large enough) + assert not any(error["loc"] == ("allowedValues", 2) for error in errors) + # Check the error message + assert "Value less than minValue" in str(excinfo.value) + + def test_allowedvalues_maxvalue_error_location_int(self) -> None: + # Test that error location reporting includes the field name and index for maxValue validation + data = { + "name": "Foo", + "type": "INT", + "maxValue": 10, + "allowedValues": [5, 10, 15], + } + + # WHEN + with pytest.raises(ValidationError) as excinfo: + _parse_model(model=JobIntParameterDefinition, obj=data) + + # THEN + errors = excinfo.value.errors() + assert len(errors) > 0 + # Check that the error location includes both "allowedValues" and the index + # Only the third value should fail (it's too large) + assert not any(error["loc"] == ("allowedValues", 0) for error in errors) + assert not any(error["loc"] == ("allowedValues", 1) for error in errors) + assert any(error["loc"] == ("allowedValues", 2) for error in errors) + # Check the error message + assert "Value larger than maxValue" in str(excinfo.value) + @pytest.mark.parametrize( "value,parameter", [ @@ -1500,6 +1689,13 @@ class TestJobFloatParameterDefinition: }, id="all fields", ), + pytest.param( + { + "name": "Foo", + "type": "FLOAT", + }, + id="allowedValues not provided", + ), ), ) def test_parse_success(self, data: dict[str, Any]) -> None: @@ -1530,6 +1726,14 @@ def test_parse_success(self, data: dict[str, Any]) -> None: pytest.param( {"name": "Foo", "type": "FLOAT", "allowedValues": []}, id="allowedValues too small" ), + pytest.param( + { + "name": "Foo", + "type": "FLOAT", + "allowedValues": None, + }, + id="allowedValues is explicitly None", + ), pytest.param( {"name": "Foo", "type": "FLOAT", "allowedValues": ["aa"]}, id="allowedValues item not number", @@ -1639,6 +1843,54 @@ def test_parse_fails(self, data: dict[str, Any]) -> None: # THEN assert len(excinfo.value.errors()) > 0 + def test_allowedvalues_minvalue_error_location_float(self) -> None: + # Test that error location reporting includes the field name and index for minValue validation + data = { + "name": "Foo", + "type": "FLOAT", + "minValue": "10.0", # Make this larger to ensure validation fails + "allowedValues": ["5.0", "6.0", "15.0"], + } + + # WHEN + with pytest.raises(ValidationError) as excinfo: + _parse_model(model=JobFloatParameterDefinition, obj=data) + + # THEN + errors = excinfo.value.errors() + assert len(errors) > 0 + # Check that the error location includes both "allowedValues" and the index + assert any(error["loc"] == ("allowedValues", 0) for error in errors) + assert any(error["loc"] == ("allowedValues", 1) for error in errors) + # Verify that the third value doesn't trigger an error (it's large enough) + assert not any(error["loc"] == ("allowedValues", 2) for error in errors) + # Check the error message + assert "Value less than minValue" in str(excinfo.value) + + def test_allowedvalues_maxvalue_error_location_float(self) -> None: + # Test that error location reporting includes the field name and index for maxValue validation + data = { + "name": "Foo", + "type": "FLOAT", + "maxValue": "10.0", + "allowedValues": ["5.0", "10.0", "15.0"], + } + + # WHEN + with pytest.raises(ValidationError) as excinfo: + _parse_model(model=JobFloatParameterDefinition, obj=data) + + # THEN + errors = excinfo.value.errors() + assert len(errors) > 0 + # Check that the error location includes both "allowedValues" and the index + # Only the third value should fail (it's too large) + assert not any(error["loc"] == ("allowedValues", 0) for error in errors) + assert not any(error["loc"] == ("allowedValues", 1) for error in errors) + assert any(error["loc"] == ("allowedValues", 2) for error in errors) + # Check the error message + assert "Value larger than maxValue" in str(excinfo.value) + @pytest.mark.parametrize( "value,parameter", [