From b5c715fb5db975c444bbbb9a3ba4834bdd74cf52 Mon Sep 17 00:00:00 2001 From: Seth Fitzsimmons Date: Tue, 3 Mar 2026 11:25:05 -0800 Subject: [PATCH 1/2] Treat None as absent in model constraint validation require_any_of, require_if, and forbid_if now treat None as "not present" for constraint purposes. Fields must be both in model_fields_set and non-null to satisfy (or violate) a constraint. - require_any_of: None no longer satisfies "at least one must be set" - require_if: None no longer satisfies "must be set when condition holds" - forbid_if: None no longer violates "must not be set when condition holds" JSON Schema generation updated to emit {"not": {"type": "null"}} property constraints alongside "required" assertions, via a shared required_non_null helper in _json_schema.py. Shared predicate _field_has_non_null_value extracted onto OptionalFieldGroupConstraint so the check lives in one place. Also fixes a stray backtick in the require_if error message. --- .../tests/division_area_baseline_schema.json | 42 +++++ .../tests/division_baseline_schema.json | 56 +++++++ .../division_boundary_baseline_schema.json | 56 +++++++ .../src/overture/schema/system/__init__.py | 14 +- .../overture/schema/system/_json_schema.py | 24 +++ .../system/model_constraint/forbid_if.py | 32 ++-- .../model_constraint/model_constraint.py | 7 + .../system/model_constraint/require_any_of.py | 45 ++--- .../system/model_constraint/require_if.py | 35 ++-- .../tests/model_constraint/test_forbid_if.py | 24 ++- .../model_constraint/test_require_any_of.py | 32 +++- .../tests/model_constraint/test_require_if.py | 25 ++- .../tests/test___json_schema.py | 42 +++++ .../tests/test_feature.py | 52 +++++- .../tests/segment_baseline_schema.json | 154 ++++++++++++++++++ 15 files changed, 565 insertions(+), 75 deletions(-) diff --git a/packages/overture-schema-divisions-theme/tests/division_area_baseline_schema.json b/packages/overture-schema-divisions-theme/tests/division_area_baseline_schema.json index f3e8c2abd..6a55d4207 100644 --- a/packages/overture-schema-divisions-theme/tests/division_area_baseline_schema.json +++ b/packages/overture-schema-divisions-theme/tests/division_area_baseline_schema.json @@ -350,6 +350,13 @@ } }, "then": { + "properties": { + "admin_level": { + "not": { + "type": "null" + } + } + }, "required": [ "admin_level" ] @@ -364,6 +371,13 @@ } }, "then": { + "properties": { + "admin_level": { + "not": { + "type": "null" + } + } + }, "required": [ "admin_level" ] @@ -378,6 +392,13 @@ } }, "then": { + "properties": { + "admin_level": { + "not": { + "type": "null" + } + } + }, "required": [ "admin_level" ] @@ -392,6 +413,13 @@ } }, "then": { + "properties": { + "admin_level": { + "not": { + "type": "null" + } + } + }, "required": [ "admin_level" ] @@ -406,6 +434,13 @@ } }, "then": { + "properties": { + "admin_level": { + "not": { + "type": "null" + } + } + }, "required": [ "admin_level" ] @@ -420,6 +455,13 @@ } }, "then": { + "properties": { + "admin_level": { + "not": { + "type": "null" + } + } + }, "required": [ "admin_level" ] diff --git a/packages/overture-schema-divisions-theme/tests/division_baseline_schema.json b/packages/overture-schema-divisions-theme/tests/division_baseline_schema.json index a2ee027b5..a34ee42d4 100644 --- a/packages/overture-schema-divisions-theme/tests/division_baseline_schema.json +++ b/packages/overture-schema-divisions-theme/tests/division_baseline_schema.json @@ -401,6 +401,13 @@ } }, "then": { + "properties": { + "admin_level": { + "not": { + "type": "null" + } + } + }, "required": [ "admin_level" ] @@ -415,6 +422,13 @@ } }, "then": { + "properties": { + "admin_level": { + "not": { + "type": "null" + } + } + }, "required": [ "admin_level" ] @@ -429,6 +443,13 @@ } }, "then": { + "properties": { + "admin_level": { + "not": { + "type": "null" + } + } + }, "required": [ "admin_level" ] @@ -443,6 +464,13 @@ } }, "then": { + "properties": { + "admin_level": { + "not": { + "type": "null" + } + } + }, "required": [ "admin_level" ] @@ -457,6 +485,13 @@ } }, "then": { + "properties": { + "admin_level": { + "not": { + "type": "null" + } + } + }, "required": [ "admin_level" ] @@ -471,6 +506,13 @@ } }, "then": { + "properties": { + "admin_level": { + "not": { + "type": "null" + } + } + }, "required": [ "admin_level" ] @@ -487,6 +529,13 @@ } }, "then": { + "properties": { + "parent_division_id": { + "not": { + "type": "null" + } + } + }, "required": [ "parent_division_id" ] @@ -502,6 +551,13 @@ }, "then": { "not": { + "properties": { + "parent_division_id": { + "not": { + "type": "null" + } + } + }, "required": [ "parent_division_id" ] diff --git a/packages/overture-schema-divisions-theme/tests/division_boundary_baseline_schema.json b/packages/overture-schema-divisions-theme/tests/division_boundary_baseline_schema.json index 336d2d484..1d310d228 100644 --- a/packages/overture-schema-divisions-theme/tests/division_boundary_baseline_schema.json +++ b/packages/overture-schema-divisions-theme/tests/division_boundary_baseline_schema.json @@ -231,6 +231,13 @@ } }, "then": { + "properties": { + "admin_level": { + "not": { + "type": "null" + } + } + }, "required": [ "admin_level" ] @@ -245,6 +252,13 @@ } }, "then": { + "properties": { + "admin_level": { + "not": { + "type": "null" + } + } + }, "required": [ "admin_level" ] @@ -259,6 +273,13 @@ } }, "then": { + "properties": { + "admin_level": { + "not": { + "type": "null" + } + } + }, "required": [ "admin_level" ] @@ -273,6 +294,13 @@ } }, "then": { + "properties": { + "admin_level": { + "not": { + "type": "null" + } + } + }, "required": [ "admin_level" ] @@ -287,6 +315,13 @@ } }, "then": { + "properties": { + "admin_level": { + "not": { + "type": "null" + } + } + }, "required": [ "admin_level" ] @@ -301,6 +336,13 @@ } }, "then": { + "properties": { + "admin_level": { + "not": { + "type": "null" + } + } + }, "required": [ "admin_level" ] @@ -317,6 +359,13 @@ } }, "then": { + "properties": { + "country": { + "not": { + "type": "null" + } + } + }, "required": [ "country" ] @@ -332,6 +381,13 @@ }, "then": { "not": { + "properties": { + "country": { + "not": { + "type": "null" + } + } + }, "required": [ "country" ] diff --git a/packages/overture-schema-system/src/overture/schema/system/__init__.py b/packages/overture-schema-system/src/overture/schema/system/__init__.py index c2ade1b8e..2177ff2fd 100644 --- a/packages/overture-schema-system/src/overture/schema/system/__init__.py +++ b/packages/overture-schema-system/src/overture/schema/system/__init__.py @@ -122,15 +122,19 @@ MyModel(foo=42, bar=None) >>> MyModel(bar="hello") # validates OK MyModel(foo=None, bar='hello') ->>> MyModel(foo=None, bar=None) # validates OK because foo and bar are explicitly set to `None` -MyModel(foo=None, bar=None) >>> >>> try: ... MyModel() ... except ValidationError as e: -... assert "at least one of these fields must be explicitly set, but none are: foo, bar" in str(e) -... print("Validation failed") -Validation failed +... assert "at least one of these fields must have a non-null value, but none do: foo, bar" in str(e) +... print("Validation failed (no fields set)") +Validation failed (no fields set) +>>> try: +... MyModel(foo=None, bar=None) +... except ValidationError as e: +... assert "at least one of these fields must have a non-null value, but none do: foo, bar" in str(e) +... print("Validation failed (all fields None)") +Validation failed (all fields None) Describe a foreign key relationship between two models where one model has a field that contains the unique identifier of another model. diff --git a/packages/overture-schema-system/src/overture/schema/system/_json_schema.py b/packages/overture-schema-system/src/overture/schema/system/_json_schema.py index c9afce006..d2c038e29 100644 --- a/packages/overture-schema-system/src/overture/schema/system/_json_schema.py +++ b/packages/overture-schema-system/src/overture/schema/system/_json_schema.py @@ -331,6 +331,30 @@ def try_move(key: str, src: JsonSchemaValue, dst: JsonSchemaValue) -> None: pass +def required_non_null(aliases: list[str]) -> JsonSchemaValue: + """ + Build a JSON Schema requiring listed fields to be present and non-null. + + Combines ``"required"`` (field key must exist) with a per-field property + constraint ``{"not": {"type": "null"}}`` (value must not be null). + + Parameters + ---------- + aliases : list[str] + Non-empty list of field aliases (JSON property names) to constrain + + Returns + ------- + JsonSchemaValue + Schema requiring each alias to be present and non-null + """ + _verify_operands_not_empty(str, aliases) + return { + "required": aliases, + "properties": {a: {"not": {"type": "null"}} for a in aliases}, + } + + T = TypeVar("T", JsonSchemaValue, str) diff --git a/packages/overture-schema-system/src/overture/schema/system/model_constraint/forbid_if.py b/packages/overture-schema-system/src/overture/schema/system/model_constraint/forbid_if.py index ab0a8a329..ca66bf8df 100644 --- a/packages/overture-schema-system/src/overture/schema/system/model_constraint/forbid_if.py +++ b/packages/overture-schema-system/src/overture/schema/system/model_constraint/forbid_if.py @@ -1,6 +1,6 @@ """ -Prohibit every field in a group of fields from having a value explicitly set, but only if a -condition is true. +Prohibit every field in a group of fields from having a non-null value, but only if a condition +is true. """ from collections.abc import Callable @@ -8,7 +8,7 @@ from pydantic import BaseModel, ConfigDict from typing_extensions import override -from .._json_schema import get_static_json_schema_extra, put_if +from .._json_schema import get_static_json_schema_extra, put_if, required_non_null from .model_constraint import ( Condition, OptionalFieldGroupConstraint, @@ -22,12 +22,13 @@ def forbid_if( ) -> Callable[[type[BaseModel]], type[BaseModel]]: """ Decorate a Pydantic model class with a constraint forbidding any of the named fields from - holding an explicitly-assigned value, but only if a field value condition is true. + holding a non-null value, but only if a field value condition is true. To ensure parity between Python and JSON Schema validation, a field's value must be explicitly - set to violate the constraint. This means in particular that fields whose value was set by - Pydantic using a default value do not count as having a set value, and fields containing the - value `None`, if this value was explicitly set rather than being inherited by default, do count. + set to a non-null value to violate the constraint. This means in particular that fields whose + value was set by Pydantic using a default value do not count as violating the prohibition, and + fields containing the value `None`, even if explicitly set, do not count as violating the + prohibition. Parameters ---------- @@ -56,11 +57,13 @@ def forbid_if( MyModel(foo='something', bar=42, baz='qux') >>> MyModel(foo='special value') # validates OK because bar/baz are omitted MyModel(foo='special value', bar=None, baz=None) + >>> MyModel(foo='special value', bar=None) # validates OK because None doesn't violate + MyModel(foo='special value', bar=None, baz=None) >>> >>> try: ... MyModel(foo='special value', bar=42) ... except ValidationError as e: - ... assert 'at least one field has an explicit value when it should not: bar' in str(e) + ... assert 'at least one field has a non-null value when it should not: bar' in str(e) ... print('Validation failed') Validation failed """ @@ -123,12 +126,14 @@ def validate_instance(self, model_instance: BaseModel) -> None: return present_fields = [ - f for f in self.field_names if f in model_instance.model_fields_set + f + for f in self.field_names + if self._field_has_non_null_value(model_instance, f) ] if present_fields: raise ValueError( - f"at least one field has an explicit value when it should not: {', '.join(present_fields)} - " + f"at least one field has a non-null value when it should not: {', '.join(present_fields)} - " f"these field value(s) are forbidden because {self.__condition} is true " f"(`{self.name}`)" ) @@ -139,12 +144,9 @@ def edit_config(self, model_class: type[BaseModel], config: ConfigDict) -> None: json_schema = get_static_json_schema_extra(config) + aliases = [apply_alias(model_class, f) for f in self.field_names] put_if( json_schema, self.__condition.json_schema(model_class), - { - "not": { - "required": [apply_alias(model_class, f) for f in self.field_names] - } - }, + {"not": required_non_null(aliases)}, ) diff --git a/packages/overture-schema-system/src/overture/schema/system/model_constraint/model_constraint.py b/packages/overture-schema-system/src/overture/schema/system/model_constraint/model_constraint.py index 9b8e0c924..3e29f025d 100644 --- a/packages/overture-schema-system/src/overture/schema/system/model_constraint/model_constraint.py +++ b/packages/overture-schema-system/src/overture/schema/system/model_constraint/model_constraint.py @@ -334,6 +334,13 @@ class OptionalFieldGroupConstraint(FieldGroupConstraint): def __init__(self, name: str | None, field_names: tuple[str, ...]): super().__init__(name, field_names) + @staticmethod + def _field_has_non_null_value(model_instance: BaseModel, field_name: str) -> bool: + return ( + field_name in model_instance.model_fields_set + and getattr(model_instance, field_name) is not None + ) + @override def validate_class(self, model_class: type[BaseModel]) -> None: super().validate_class(model_class) diff --git a/packages/overture-schema-system/src/overture/schema/system/model_constraint/require_any_of.py b/packages/overture-schema-system/src/overture/schema/system/model_constraint/require_any_of.py index e131c0a66..1265d9f9b 100644 --- a/packages/overture-schema-system/src/overture/schema/system/model_constraint/require_any_of.py +++ b/packages/overture-schema-system/src/overture/schema/system/model_constraint/require_any_of.py @@ -1,28 +1,27 @@ """ -Require at least one named field to have a value explicitly set. +Require at least one named field to have a non-null value. """ from collections.abc import Callable from pydantic import BaseModel, ConfigDict -from pydantic.json_schema import JsonDict from typing_extensions import override -from .._json_schema import get_static_json_schema_extra, put_any_of +from .._json_schema import get_static_json_schema_extra, put_any_of, required_non_null from .model_constraint import OptionalFieldGroupConstraint, apply_alias def require_any_of(*field_names: str) -> Callable[[type[BaseModel]], type[BaseModel]]: """ Decorate a Pydantic model class with a constraint requiring that at least one of the named - fields has a value explicitly set. + fields has a non-null value. This function is the decorator version of the `RequireAnyOfConstraint` class. To ensure parity between Python and JSON Schema validation, a field's value must be explicitly - set to satisfy the constraint. This means in particular that fields whose value was set by - Pydantic using a default value do not count as having a set value, and fields containing the - value `None`, if this value was explicitly set rather than being inherited by default, do count. + set to a non-null value to satisfy the constraint. Fields whose value was set by Pydantic using + a default value do not count, and fields explicitly set to `None` do not count as satisfying + the constraint. Parameters ---------- @@ -49,16 +48,21 @@ def require_any_of(*field_names: str) -> Callable[[type[BaseModel]], type[BaseMo MyModel(foo=42, bar=None) >>> MyModel(bar="hello") # validates OK MyModel(foo=None, bar='hello') - >>> MyModel(foo=None, bar=None) # validates OK - MyModel(foo=None, bar=None) >>> >>> try: ... MyModel() ... except ValidationError as e: - ... assert "at least one of these fields must be explicitly set, but none are: foo, bar" \ + ... assert "at least one of these fields must have a non-null value, but none do: foo, bar" \ in str(e) - ... print("Validation failed") - Validation failed + ... print("Validation failed (no fields set)") + Validation failed (no fields set) + >>> try: + ... MyModel(foo=None, bar=None) + ... except ValidationError as e: + ... assert "at least one of these fields must have a non-null value, but none do: foo, bar" \ + in str(e) + ... print("Validation failed (all fields None)") + Validation failed (all fields None) """ model_constraint = RequireAnyOfConstraint._create_internal( f"@{require_any_of.__name__}", *field_names @@ -97,11 +101,11 @@ def __validate_field_names(field_names: tuple[str, ...]) -> tuple[str, ...]: def validate_instance(self, model_instance: BaseModel) -> None: super().validate_instance(model_instance) - if not ( - any(f for f in self.field_names if f in model_instance.model_fields_set) + if not any( + self._field_has_non_null_value(model_instance, f) for f in self.field_names ): raise ValueError( - f"at least one of these fields must be explicitly set, but none are: {', '.join(self.field_names)} (`{self.name}`)" + f"at least one of these fields must have a non-null value, but none do: {', '.join(self.field_names)} (`{self.name}`)" ) @override @@ -110,7 +114,10 @@ def edit_config(self, model_class: type[BaseModel], config: ConfigDict) -> None: json_schema = get_static_json_schema_extra(config) - def required(field_name: str) -> JsonDict: - return {"required": [apply_alias(model_class, field_name)]} - - put_any_of(json_schema, [required(f) for f in self.field_names]) + put_any_of( + json_schema, + [ + required_non_null([apply_alias(model_class, f)]) + for f in self.field_names + ], + ) diff --git a/packages/overture-schema-system/src/overture/schema/system/model_constraint/require_if.py b/packages/overture-schema-system/src/overture/schema/system/model_constraint/require_if.py index 4cb5b138e..4a84e1a67 100644 --- a/packages/overture-schema-system/src/overture/schema/system/model_constraint/require_if.py +++ b/packages/overture-schema-system/src/overture/schema/system/model_constraint/require_if.py @@ -1,6 +1,5 @@ """ -Require every field in a group of fields to have a value explicitly set, but only if a condition is -true. +Require every field in a group of fields to have a non-null value, but only if a condition is true. """ from collections.abc import Callable @@ -8,7 +7,7 @@ from pydantic import BaseModel, ConfigDict from typing_extensions import override -from .._json_schema import get_static_json_schema_extra, put_if +from .._json_schema import get_static_json_schema_extra, put_if, required_non_null from .model_constraint import ( Condition, OptionalFieldGroupConstraint, @@ -22,12 +21,12 @@ def require_if( ) -> Callable[[type[BaseModel]], type[BaseModel]]: """ Decorate a Pydantic model class with a constraint requiring all of the named fields to have a - value explicitly set, but only if a condition is true. + non-null value, but only if a condition is true. To ensure parity between Python and JSON Schema validation, a field's value must be explicitly - set to satisfy the constraint. This means in particular that fields whose value was set by - Pydantic using a default value do not count as having a set value, and fields containing the - value `None`, if this value was explicitly set rather than being inherited by default, do count. + set to a non-null value to satisfy the constraint. This means in particular that fields whose + value was set by Pydantic using a default value do not count as having a set value, and fields + containing the value `None`, even if explicitly set, do not count as satisfying the constraint. Parameters ---------- @@ -61,7 +60,16 @@ def require_if( ... MyModel(foo='special value') ... except ValidationError as e: ... assert ( - ... 'at least one field is missing an explicit value when it should have one: bar, baz' + ... 'at least one field is missing a non-null value when it should have one: bar, baz' + ... ) in str(e) + ... print('Validation failed') + Validation failed + >>> + >>> try: + ... MyModel(foo='special value', bar=None, baz=None) + ... except ValidationError as e: + ... assert ( + ... 'at least one field is missing a non-null value when it should have one: bar, baz' ... ) in str(e) ... print('Validation failed') Validation failed @@ -125,13 +133,15 @@ def validate_instance(self, model_instance: BaseModel) -> None: return missing_fields = [ - f for f in self.field_names if f not in model_instance.model_fields_set + f + for f in self.field_names + if not self._field_has_non_null_value(model_instance, f) ] if missing_fields: raise ValueError( - f"at least one field is missing an explicit value when it should have one: {', '.join(missing_fields)} - " - f"these field value(s) are required because {self.__condition} is true` (`{self.name}`)" + f"at least one field is missing a non-null value when it should have one: {', '.join(missing_fields)} - " + f"these field value(s) are required because {self.__condition} is true (`{self.name}`)" ) @override @@ -140,8 +150,9 @@ def edit_config(self, model_class: type[BaseModel], config: ConfigDict) -> None: json_schema = get_static_json_schema_extra(config) + aliases = [apply_alias(model_class, f) for f in self.field_names] put_if( json_schema, self.__condition.json_schema(model_class), - {"required": [apply_alias(model_class, f) for f in self.field_names]}, + required_non_null(aliases), ) diff --git a/packages/overture-schema-system/tests/model_constraint/test_forbid_if.py b/packages/overture-schema-system/tests/model_constraint/test_forbid_if.py index 96d5c40a1..06c9d6b6c 100644 --- a/packages/overture-schema-system/tests/model_constraint/test_forbid_if.py +++ b/packages/overture-schema-system/tests/model_constraint/test_forbid_if.py @@ -6,6 +6,7 @@ from util import assert_subset from overture.schema.system import create_model +from overture.schema.system._json_schema import required_non_null from overture.schema.system.model_constraint import ( Condition, FieldEqCondition, @@ -77,7 +78,7 @@ class TestModel(BaseModel): constraint.validate_class(TestModel) with pytest.raises( - ValueError, match="at least one field has an explicit value when it should not" + ValueError, match="at least one field has a non-null value when it should not" ): constraint.validate_instance(model_instance) @@ -105,6 +106,19 @@ class TestModel(BaseModel): constraint.validate_instance(TestModel(baz=42)) +def test_valid_model_instance_fields_explicitly_none_when_condition_true() -> None: + """Setting a forbidden field to None does not violate the prohibition.""" + + class TestModel(BaseModel): + foo: int | None = None + bar: int | None = None + baz: int + + constraint = ForbidIfConstraint(["foo", "bar"], FieldEqCondition("baz", 42)) + # foo and bar are explicitly set to None, but None doesn't count as a value + constraint.validate_instance(TestModel(foo=None, bar=None, baz=42)) + + @pytest.mark.parametrize("field_names", [["foo"], ["bar"], ["foo", "bar"]]) def test_valid_model_instance_condition_false(field_names: list[str]) -> None: class TestModel(BaseModel): @@ -126,7 +140,7 @@ class TestModel(BaseModel): actual = TestModel.model_json_schema() expect = { "if": {"properties": {"qux": {"const": 42}}}, - "then": {"not": {"required": ["foo", "baz"]}}, + "then": {"not": required_non_null(["foo", "baz"])}, } assert expect == TestModel.model_config["json_schema_extra"] assert_subset(expect, actual, "expect", "actual") @@ -139,7 +153,7 @@ class TestModel(BaseModel): None, { "if": {"not": {"properties": {"corge": {"const": 42}}}}, - "then": {"not": {"required": ["bar", "baz"]}}, + "then": {"not": required_non_null(["bar", "baz"])}, }, ), ( @@ -147,7 +161,7 @@ class TestModel(BaseModel): { "random": "value", "if": {"not": {"properties": {"corge": {"const": 42}}}}, - "then": {"not": {"required": ["bar", "baz"]}}, + "then": {"not": required_non_null(["bar", "baz"])}, }, ), ( @@ -157,7 +171,7 @@ class TestModel(BaseModel): {"if": 123}, { "if": {"not": {"properties": {"corge": {"const": 42}}}}, - "then": {"not": {"required": ["bar", "baz"]}}, + "then": {"not": required_non_null(["bar", "baz"])}, }, ] }, diff --git a/packages/overture-schema-system/tests/model_constraint/test_require_any_of.py b/packages/overture-schema-system/tests/model_constraint/test_require_any_of.py index 5209c06b0..a876b9a95 100644 --- a/packages/overture-schema-system/tests/model_constraint/test_require_any_of.py +++ b/packages/overture-schema-system/tests/model_constraint/test_require_any_of.py @@ -3,6 +3,7 @@ from pydantic.json_schema import JsonDict from util import assert_subset +from overture.schema.system._json_schema import required_non_null from overture.schema.system.model_constraint import ( ModelConstraint, RequireAnyOfConstraint, @@ -43,7 +44,8 @@ class TestModel2(BaseModel): RequireAnyOfConstraint("foo", "bar").validate_class(TestModel2) -def test_error_invalid_model_instance() -> None: +@pytest.mark.parametrize("kwargs", [{}, {"foo": None, "bar": None}]) +def test_error_no_non_null_value(kwargs: dict[str, object]) -> None: @require_any_of("foo", "bar") class TestModel(BaseModel): foo: int | None = None @@ -51,9 +53,9 @@ class TestModel(BaseModel): with pytest.raises( ValidationError, - match="at least one of these fields must be explicitly set, but none are: foo, bar", + match="at least one of these fields must have a non-null value, but none do: foo, bar", ): - TestModel() + TestModel(**kwargs) @pytest.mark.parametrize("foo,bar", [(42, "hello"), (42, None), (None, "hello")]) @@ -73,7 +75,12 @@ class TestModel(BaseModel): bar: str | None = Field(default=None, alias="baz") actual = TestModel.model_json_schema() - expect = {"anyOf": [{"required": ["foo"]}, {"required": ["baz"]}]} + expect = { + "anyOf": [ + required_non_null(["foo"]), + required_non_null(["baz"]), + ] + } assert expect == TestModel.model_config["json_schema_extra"] assert_subset(expect, actual, "expect", "actual") @@ -81,13 +88,26 @@ class TestModel(BaseModel): @pytest.mark.parametrize( "base_json_schema,expect", [ - (None, {"anyOf": [{"required": ["foo"]}, {"required": ["baz"]}]}), + ( + None, + { + "anyOf": [ + required_non_null(["foo"]), + required_non_null(["baz"]), + ] + }, + ), ( {"anyOf": "anything"}, { "allOf": [ {"anyOf": "anything"}, - {"anyOf": [{"required": ["foo"]}, {"required": ["baz"]}]}, + { + "anyOf": [ + required_non_null(["foo"]), + required_non_null(["baz"]), + ] + }, ] }, ), diff --git a/packages/overture-schema-system/tests/model_constraint/test_require_if.py b/packages/overture-schema-system/tests/model_constraint/test_require_if.py index b3ec64705..1f59a5921 100644 --- a/packages/overture-schema-system/tests/model_constraint/test_require_if.py +++ b/packages/overture-schema-system/tests/model_constraint/test_require_if.py @@ -6,6 +6,7 @@ from util import assert_subset from overture.schema.system import create_model +from overture.schema.system._json_schema import required_non_null from overture.schema.system.model_constraint import ( Condition, FieldEqCondition, @@ -78,7 +79,7 @@ class TestModel(BaseModel): constraint.validate_class(TestModel) with pytest.raises( ValueError, - match="at least one field is missing an explicit value when it should have one:", + match="at least one field is missing a non-null value when it should have one:", ): constraint.validate_instance(model_instance) @@ -117,6 +118,20 @@ class TestModel(BaseModel): constraint.validate_instance(TestModel(bar=41, baz=42)) +def test_error_fields_explicitly_none_when_condition_true() -> None: + class TestModel(BaseModel): + foo: int | None = None + bar: int | None = None + baz: int + + constraint = RequireIfConstraint(["foo", "bar"], FieldEqCondition("baz", 42)) + with pytest.raises( + ValueError, + match="at least one field is missing a non-null value when it should have one", + ): + constraint.validate_instance(TestModel(foo=None, bar=None, baz=42)) + + def test_model_json_schema_no_model_config() -> None: @require_if(["foo", "baz"], FieldEqCondition("qux", 42)) class TestModel(BaseModel): @@ -127,7 +142,7 @@ class TestModel(BaseModel): actual = TestModel.model_json_schema() expect = { "if": {"properties": {"corge": {"const": 42}}}, - "then": {"required": ["bar", "baz"]}, + "then": required_non_null(["bar", "baz"]), } assert expect == TestModel.model_config["json_schema_extra"] assert_subset(expect, actual, "expect", "actual") @@ -140,7 +155,7 @@ class TestModel(BaseModel): None, { "if": {"not": {"properties": {"qux": {"const": 42}}}}, - "then": {"required": ["foo", "baz"]}, + "then": required_non_null(["foo", "baz"]), }, ), ( @@ -148,7 +163,7 @@ class TestModel(BaseModel): { "random": "value", "if": {"not": {"properties": {"qux": {"const": 42}}}}, - "then": {"required": ["foo", "baz"]}, + "then": required_non_null(["foo", "baz"]), }, ), ( @@ -158,7 +173,7 @@ class TestModel(BaseModel): {"if": 123}, { "if": {"not": {"properties": {"qux": {"const": 42}}}}, - "then": {"required": ["foo", "baz"]}, + "then": required_non_null(["foo", "baz"]), }, ] }, diff --git a/packages/overture-schema-system/tests/test___json_schema.py b/packages/overture-schema-system/tests/test___json_schema.py index 85769b805..e820f17e8 100644 --- a/packages/overture-schema-system/tests/test___json_schema.py +++ b/packages/overture-schema-system/tests/test___json_schema.py @@ -14,6 +14,7 @@ put_one_of, put_properties, put_required, + required_non_null, try_move, ) @@ -561,3 +562,44 @@ def test_try_move_missing_key() -> None: assert {"foo": "bar"} == src assert {} == dst + + +#################################################################################################### +# required_non_null # +#################################################################################################### + + +@pytest.mark.parametrize( + "aliases,expect", + [ + ( + ["foo"], + { + "required": ["foo"], + "properties": {"foo": {"not": {"type": "null"}}}, + }, + ), + ( + ["foo", "bar"], + { + "required": ["foo", "bar"], + "properties": { + "foo": {"not": {"type": "null"}}, + "bar": {"not": {"type": "null"}}, + }, + }, + ), + ], +) +def test_required_non_null_success(aliases: list[str], expect: JsonSchemaValue) -> None: + assert expect == required_non_null(aliases) + + +def test_required_non_null_error_empty() -> None: + with pytest.raises(ValueError, match="`operands` cannot be empty"): + required_non_null([]) + + +def test_required_non_null_error_not_list() -> None: + with pytest.raises(TypeError, match="`operands` must be a `list`"): + required_non_null(cast(list[str], "foo")) diff --git a/packages/overture-schema-system/tests/test_feature.py b/packages/overture-schema-system/tests/test_feature.py index 49dfbe85f..394d8bdea 100644 --- a/packages/overture-schema-system/tests/test_feature.py +++ b/packages/overture-schema-system/tests/test_feature.py @@ -1510,8 +1510,14 @@ class PropertiesObjectConstraintFeature(Feature): "properties": { "required": ["baz"], "anyOf": [ - {"required": ["foo"]}, - {"required": ["bar"]}, + { + "required": ["foo"], + "properties": {"foo": {"not": {"type": "null"}}}, + }, + { + "required": ["bar"], + "properties": {"bar": {"not": {"type": "null"}}}, + }, ], "if": { "properties": { @@ -1521,7 +1527,10 @@ class PropertiesObjectConstraintFeature(Feature): }, }, "then": { - "not": {"required": ["bar"]}, + "not": { + "required": ["bar"], + "properties": {"bar": {"not": {"type": "null"}}}, + }, }, }, }, @@ -1556,12 +1565,18 @@ class MixedConstraintFeature(Feature): "properties", ], "anyOf": [ - {"required": ["bbox"]}, + { + "required": ["bbox"], + "properties": {"bbox": {"not": {"type": "null"}}}, + }, { "properties": { "properties": { "type": "object", - "required": ["foo"], + "properties": { + "foo": {"not": {"type": "null"}}, + "properties": {"type": "object", "required": ["foo"]}, + }, } }, }, @@ -1569,7 +1584,13 @@ class MixedConstraintFeature(Feature): "properties": { "properties": { "type": "object", - "required": ["garply"], + "properties": { + "garply": {"not": {"type": "null"}}, + "properties": { + "type": "object", + "required": ["garply"], + }, + }, }, }, }, @@ -1591,9 +1612,17 @@ class MixedConstraintFeature(Feature): "then": { "required": ["id"], "properties": { + "id": {"not": {"type": "null"}}, "properties": { "type": "object", - "required": ["foo", "qux"], + "properties": { + "foo": {"not": {"type": "null"}}, + "qux": {"not": {"type": "null"}}, + "properties": { + "type": "object", + "required": ["foo", "qux"], + }, + }, }, }, }, @@ -1616,7 +1645,14 @@ class MixedConstraintFeature(Feature): "properties": { "properties": { "type": "object", - "required": ["foo", "type"], + "properties": { + "foo": {"not": {"type": "null"}}, + "type": {"not": {"type": "null"}}, + "properties": { + "type": "object", + "required": ["foo", "type"], + }, + }, } } }, diff --git a/packages/overture-schema-transportation-theme/tests/segment_baseline_schema.json b/packages/overture-schema-transportation-theme/tests/segment_baseline_schema.json index b863d3b3a..2218297ef 100644 --- a/packages/overture-schema-transportation-theme/tests/segment_baseline_schema.json +++ b/packages/overture-schema-transportation-theme/tests/segment_baseline_schema.json @@ -100,11 +100,25 @@ "additionalProperties": false, "anyOf": [ { + "properties": { + "labels": { + "not": { + "type": "null" + } + } + }, "required": [ "labels" ] }, { + "properties": { + "symbols": { + "not": { + "type": "null" + } + } + }, "required": [ "symbols" ] @@ -1194,11 +1208,25 @@ "additionalProperties": false, "anyOf": [ { + "properties": { + "max_speed": { + "not": { + "type": "null" + } + } + }, "required": [ "max_speed" ] }, { + "properties": { + "min_speed": { + "not": { + "type": "null" + } + } + }, "required": [ "min_speed" ] @@ -1715,31 +1743,73 @@ "additionalProperties": false, "anyOf": [ { + "properties": { + "heading": { + "not": { + "type": "null" + } + } + }, "required": [ "heading" ] }, { + "properties": { + "during": { + "not": { + "type": "null" + } + } + }, "required": [ "during" ] }, { + "properties": { + "mode": { + "not": { + "type": "null" + } + } + }, "required": [ "mode" ] }, { + "properties": { + "using": { + "not": { + "type": "null" + } + } + }, "required": [ "using" ] }, { + "properties": { + "recognized": { + "not": { + "type": "null" + } + } + }, "required": [ "recognized" ] }, { + "properties": { + "vehicle": { + "not": { + "type": "null" + } + } + }, "required": [ "vehicle" ] @@ -1836,31 +1906,73 @@ "additionalProperties": false, "anyOf": [ { + "properties": { + "heading": { + "not": { + "type": "null" + } + } + }, "required": [ "heading" ] }, { + "properties": { + "during": { + "not": { + "type": "null" + } + } + }, "required": [ "during" ] }, { + "properties": { + "mode": { + "not": { + "type": "null" + } + } + }, "required": [ "mode" ] }, { + "properties": { + "using": { + "not": { + "type": "null" + } + } + }, "required": [ "using" ] }, { + "properties": { + "recognized": { + "not": { + "type": "null" + } + } + }, "required": [ "recognized" ] }, { + "properties": { + "vehicle": { + "not": { + "type": "null" + } + } + }, "required": [ "vehicle" ] @@ -1942,31 +2054,73 @@ "additionalProperties": false, "anyOf": [ { + "properties": { + "heading": { + "not": { + "type": "null" + } + } + }, "required": [ "heading" ] }, { + "properties": { + "during": { + "not": { + "type": "null" + } + } + }, "required": [ "during" ] }, { + "properties": { + "mode": { + "not": { + "type": "null" + } + } + }, "required": [ "mode" ] }, { + "properties": { + "using": { + "not": { + "type": "null" + } + } + }, "required": [ "using" ] }, { + "properties": { + "recognized": { + "not": { + "type": "null" + } + } + }, "required": [ "recognized" ] }, { + "properties": { + "vehicle": { + "not": { + "type": "null" + } + } + }, "required": [ "vehicle" ] From ce088049b5ef5c0a2f2f4d63f0520a0494b97fbc Mon Sep 17 00:00:00 2001 From: Seth Fitzsimmons Date: Thu, 5 Mar 2026 11:32:31 -0800 Subject: [PATCH 2/2] Use Python None terminology and clarify constraint docs Use Python terminology (None) instead of null in docstrings, identifiers, and error messages. Rewrite error messages to describe what's checked ("set to a value other than None") rather than using jargon. --- .../src/overture/schema/system/__init__.py | 4 ++-- .../overture/schema/system/_json_schema.py | 10 ++++----- .../system/model_constraint/forbid_if.py | 22 +++++++++---------- .../model_constraint/model_constraint.py | 2 +- .../system/model_constraint/require_any_of.py | 17 +++++++------- .../system/model_constraint/require_if.py | 21 +++++++++--------- .../tests/model_constraint/test_forbid_if.py | 4 ++-- .../model_constraint/test_require_any_of.py | 2 +- .../tests/model_constraint/test_require_if.py | 4 ++-- 9 files changed, 42 insertions(+), 44 deletions(-) diff --git a/packages/overture-schema-system/src/overture/schema/system/__init__.py b/packages/overture-schema-system/src/overture/schema/system/__init__.py index 2177ff2fd..1e4b8be72 100644 --- a/packages/overture-schema-system/src/overture/schema/system/__init__.py +++ b/packages/overture-schema-system/src/overture/schema/system/__init__.py @@ -126,13 +126,13 @@ >>> try: ... MyModel() ... except ValidationError as e: -... assert "at least one of these fields must have a non-null value, but none do: foo, bar" in str(e) +... assert "at least one of these fields must be set to a value other than None, but none are: foo, bar" in str(e) ... print("Validation failed (no fields set)") Validation failed (no fields set) >>> try: ... MyModel(foo=None, bar=None) ... except ValidationError as e: -... assert "at least one of these fields must have a non-null value, but none do: foo, bar" in str(e) +... assert "at least one of these fields must be set to a value other than None, but none are: foo, bar" in str(e) ... print("Validation failed (all fields None)") Validation failed (all fields None) diff --git a/packages/overture-schema-system/src/overture/schema/system/_json_schema.py b/packages/overture-schema-system/src/overture/schema/system/_json_schema.py index d2c038e29..64bc288ac 100644 --- a/packages/overture-schema-system/src/overture/schema/system/_json_schema.py +++ b/packages/overture-schema-system/src/overture/schema/system/_json_schema.py @@ -333,20 +333,20 @@ def try_move(key: str, src: JsonSchemaValue, dst: JsonSchemaValue) -> None: def required_non_null(aliases: list[str]) -> JsonSchemaValue: """ - Build a JSON Schema requiring listed fields to be present and non-null. + Build a JSON Schema requiring listed properties to be present and non-null. - Combines ``"required"`` (field key must exist) with a per-field property - constraint ``{"not": {"type": "null"}}`` (value must not be null). + Combines `"required"` (property must exist) with a per-property + constraint `{"not": {"type": "null"}}` (value must not be null). Parameters ---------- aliases : list[str] - Non-empty list of field aliases (JSON property names) to constrain + Non-empty list of JSON Schema property names to constrain Returns ------- JsonSchemaValue - Schema requiring each alias to be present and non-null + Schema requiring each property to be present and non-null """ _verify_operands_not_empty(str, aliases) return { diff --git a/packages/overture-schema-system/src/overture/schema/system/model_constraint/forbid_if.py b/packages/overture-schema-system/src/overture/schema/system/model_constraint/forbid_if.py index ca66bf8df..c301edbd4 100644 --- a/packages/overture-schema-system/src/overture/schema/system/model_constraint/forbid_if.py +++ b/packages/overture-schema-system/src/overture/schema/system/model_constraint/forbid_if.py @@ -1,6 +1,6 @@ """ -Prohibit every field in a group of fields from having a non-null value, but only if a condition -is true. +Prohibit every field in a group of fields from having a non-`None` value, but only if a +condition is true. """ from collections.abc import Callable @@ -22,13 +22,11 @@ def forbid_if( ) -> Callable[[type[BaseModel]], type[BaseModel]]: """ Decorate a Pydantic model class with a constraint forbidding any of the named fields from - holding a non-null value, but only if a field value condition is true. + holding a non-`None` value, but only if a field value condition is true. To ensure parity between Python and JSON Schema validation, a field's value must be explicitly - set to a non-null value to violate the constraint. This means in particular that fields whose - value was set by Pydantic using a default value do not count as violating the prohibition, and - fields containing the value `None`, even if explicitly set, do not count as violating the - prohibition. + set to a non-`None` value to violate the constraint. Fields containing the value `None`, + whether set explicitly or by default, are always compliant. Parameters ---------- @@ -57,13 +55,13 @@ def forbid_if( MyModel(foo='something', bar=42, baz='qux') >>> MyModel(foo='special value') # validates OK because bar/baz are omitted MyModel(foo='special value', bar=None, baz=None) - >>> MyModel(foo='special value', bar=None) # validates OK because None doesn't violate + >>> MyModel(foo='special value', bar=None) # validates OK because None is compliant MyModel(foo='special value', bar=None, baz=None) >>> >>> try: ... MyModel(foo='special value', bar=42) ... except ValidationError as e: - ... assert 'at least one field has a non-null value when it should not: bar' in str(e) + ... assert 'at least one field is set to a value other than None when it must not be: bar' in str(e) ... print('Validation failed') Validation failed """ @@ -128,13 +126,13 @@ def validate_instance(self, model_instance: BaseModel) -> None: present_fields = [ f for f in self.field_names - if self._field_has_non_null_value(model_instance, f) + if self._field_has_non_none_value(model_instance, f) ] if present_fields: raise ValueError( - f"at least one field has a non-null value when it should not: {', '.join(present_fields)} - " - f"these field value(s) are forbidden because {self.__condition} is true " + f"at least one field is set to a value other than None when it must not be: {', '.join(present_fields)} - " + f"these field(s) are forbidden because {self.__condition} is true " f"(`{self.name}`)" ) diff --git a/packages/overture-schema-system/src/overture/schema/system/model_constraint/model_constraint.py b/packages/overture-schema-system/src/overture/schema/system/model_constraint/model_constraint.py index 3e29f025d..ece264872 100644 --- a/packages/overture-schema-system/src/overture/schema/system/model_constraint/model_constraint.py +++ b/packages/overture-schema-system/src/overture/schema/system/model_constraint/model_constraint.py @@ -335,7 +335,7 @@ def __init__(self, name: str | None, field_names: tuple[str, ...]): super().__init__(name, field_names) @staticmethod - def _field_has_non_null_value(model_instance: BaseModel, field_name: str) -> bool: + def _field_has_non_none_value(model_instance: BaseModel, field_name: str) -> bool: return ( field_name in model_instance.model_fields_set and getattr(model_instance, field_name) is not None diff --git a/packages/overture-schema-system/src/overture/schema/system/model_constraint/require_any_of.py b/packages/overture-schema-system/src/overture/schema/system/model_constraint/require_any_of.py index 1265d9f9b..f05e86043 100644 --- a/packages/overture-schema-system/src/overture/schema/system/model_constraint/require_any_of.py +++ b/packages/overture-schema-system/src/overture/schema/system/model_constraint/require_any_of.py @@ -1,5 +1,5 @@ """ -Require at least one named field to have a non-null value. +Require at least one named field to have a non-`None` value. """ from collections.abc import Callable @@ -14,14 +14,13 @@ def require_any_of(*field_names: str) -> Callable[[type[BaseModel]], type[BaseModel]]: """ Decorate a Pydantic model class with a constraint requiring that at least one of the named - fields has a non-null value. + fields has a non-`None` value. This function is the decorator version of the `RequireAnyOfConstraint` class. To ensure parity between Python and JSON Schema validation, a field's value must be explicitly - set to a non-null value to satisfy the constraint. Fields whose value was set by Pydantic using - a default value do not count, and fields explicitly set to `None` do not count as satisfying - the constraint. + set to a non-`None` value to satisfy the constraint. Fields whose value was set by Pydantic + using a default value violate the constraint, as do fields explicitly set to `None`. Parameters ---------- @@ -52,14 +51,14 @@ def require_any_of(*field_names: str) -> Callable[[type[BaseModel]], type[BaseMo >>> try: ... MyModel() ... except ValidationError as e: - ... assert "at least one of these fields must have a non-null value, but none do: foo, bar" \ + ... assert "at least one of these fields must be set to a value other than None, but none are: foo, bar" \ in str(e) ... print("Validation failed (no fields set)") Validation failed (no fields set) >>> try: ... MyModel(foo=None, bar=None) ... except ValidationError as e: - ... assert "at least one of these fields must have a non-null value, but none do: foo, bar" \ + ... assert "at least one of these fields must be set to a value other than None, but none are: foo, bar" \ in str(e) ... print("Validation failed (all fields None)") Validation failed (all fields None) @@ -102,10 +101,10 @@ def validate_instance(self, model_instance: BaseModel) -> None: super().validate_instance(model_instance) if not any( - self._field_has_non_null_value(model_instance, f) for f in self.field_names + self._field_has_non_none_value(model_instance, f) for f in self.field_names ): raise ValueError( - f"at least one of these fields must have a non-null value, but none do: {', '.join(self.field_names)} (`{self.name}`)" + f"at least one of these fields must be set to a value other than None, but none are: {', '.join(self.field_names)} (`{self.name}`)" ) @override diff --git a/packages/overture-schema-system/src/overture/schema/system/model_constraint/require_if.py b/packages/overture-schema-system/src/overture/schema/system/model_constraint/require_if.py index 4a84e1a67..fbc354d2f 100644 --- a/packages/overture-schema-system/src/overture/schema/system/model_constraint/require_if.py +++ b/packages/overture-schema-system/src/overture/schema/system/model_constraint/require_if.py @@ -1,5 +1,6 @@ """ -Require every field in a group of fields to have a non-null value, but only if a condition is true. +Require every field in a group of fields to have a non-`None` value, but only if a condition is +true. """ from collections.abc import Callable @@ -21,12 +22,12 @@ def require_if( ) -> Callable[[type[BaseModel]], type[BaseModel]]: """ Decorate a Pydantic model class with a constraint requiring all of the named fields to have a - non-null value, but only if a condition is true. + non-`None` value, but only if a condition is true. To ensure parity between Python and JSON Schema validation, a field's value must be explicitly - set to a non-null value to satisfy the constraint. This means in particular that fields whose - value was set by Pydantic using a default value do not count as having a set value, and fields - containing the value `None`, even if explicitly set, do not count as satisfying the constraint. + set to a non-`None` value to satisfy the constraint. Fields whose value was set by Pydantic + using a default are treated as absent and violate the constraint, as do fields explicitly set + to `None`. Parameters ---------- @@ -60,7 +61,7 @@ def require_if( ... MyModel(foo='special value') ... except ValidationError as e: ... assert ( - ... 'at least one field is missing a non-null value when it should have one: bar, baz' + ... 'at least one field is not set to a value other than None: bar, baz' ... ) in str(e) ... print('Validation failed') Validation failed @@ -69,7 +70,7 @@ def require_if( ... MyModel(foo='special value', bar=None, baz=None) ... except ValidationError as e: ... assert ( - ... 'at least one field is missing a non-null value when it should have one: bar, baz' + ... 'at least one field is not set to a value other than None: bar, baz' ... ) in str(e) ... print('Validation failed') Validation failed @@ -135,13 +136,13 @@ def validate_instance(self, model_instance: BaseModel) -> None: missing_fields = [ f for f in self.field_names - if not self._field_has_non_null_value(model_instance, f) + if not self._field_has_non_none_value(model_instance, f) ] if missing_fields: raise ValueError( - f"at least one field is missing a non-null value when it should have one: {', '.join(missing_fields)} - " - f"these field value(s) are required because {self.__condition} is true (`{self.name}`)" + f"at least one field is not set to a value other than None: {', '.join(missing_fields)} - " + f"these field(s) are required because {self.__condition} is true (`{self.name}`)" ) @override diff --git a/packages/overture-schema-system/tests/model_constraint/test_forbid_if.py b/packages/overture-schema-system/tests/model_constraint/test_forbid_if.py index 06c9d6b6c..090993442 100644 --- a/packages/overture-schema-system/tests/model_constraint/test_forbid_if.py +++ b/packages/overture-schema-system/tests/model_constraint/test_forbid_if.py @@ -78,7 +78,8 @@ class TestModel(BaseModel): constraint.validate_class(TestModel) with pytest.raises( - ValueError, match="at least one field has a non-null value when it should not" + ValueError, + match="at least one field is set to a value other than None when it must not be", ): constraint.validate_instance(model_instance) @@ -115,7 +116,6 @@ class TestModel(BaseModel): baz: int constraint = ForbidIfConstraint(["foo", "bar"], FieldEqCondition("baz", 42)) - # foo and bar are explicitly set to None, but None doesn't count as a value constraint.validate_instance(TestModel(foo=None, bar=None, baz=42)) diff --git a/packages/overture-schema-system/tests/model_constraint/test_require_any_of.py b/packages/overture-schema-system/tests/model_constraint/test_require_any_of.py index a876b9a95..6c28dec04 100644 --- a/packages/overture-schema-system/tests/model_constraint/test_require_any_of.py +++ b/packages/overture-schema-system/tests/model_constraint/test_require_any_of.py @@ -53,7 +53,7 @@ class TestModel(BaseModel): with pytest.raises( ValidationError, - match="at least one of these fields must have a non-null value, but none do: foo, bar", + match="at least one of these fields must be set to a value other than None, but none are: foo, bar", ): TestModel(**kwargs) diff --git a/packages/overture-schema-system/tests/model_constraint/test_require_if.py b/packages/overture-schema-system/tests/model_constraint/test_require_if.py index 1f59a5921..2f6f16471 100644 --- a/packages/overture-schema-system/tests/model_constraint/test_require_if.py +++ b/packages/overture-schema-system/tests/model_constraint/test_require_if.py @@ -79,7 +79,7 @@ class TestModel(BaseModel): constraint.validate_class(TestModel) with pytest.raises( ValueError, - match="at least one field is missing a non-null value when it should have one:", + match="at least one field is not set to a value other than None:", ): constraint.validate_instance(model_instance) @@ -127,7 +127,7 @@ class TestModel(BaseModel): constraint = RequireIfConstraint(["foo", "bar"], FieldEqCondition("baz", 42)) with pytest.raises( ValueError, - match="at least one field is missing a non-null value when it should have one", + match="at least one field is not set to a value other than None", ): constraint.validate_instance(TestModel(foo=None, bar=None, baz=42))