From d6c65493a8436b22733d0f04d0bb3df1bc952ac9 Mon Sep 17 00:00:00 2001 From: Viicos <65306057+Viicos@users.noreply.github.com> Date: Fri, 16 May 2025 15:46:24 +0200 Subject: [PATCH 1/8] Add `UNSET` sentinel --- pydantic/_internal/_generate_schema.py | 3 + pydantic/fields.py | 4 +- pydantic/json_schema.py | 7 +- pyproject.toml | 2 +- 5 files changed, 15 insertions(+), 122 deletions(-) Index: pydantic-2.11.7/pydantic/_internal/_generate_schema.py =================================================================== --- pydantic-2.11.7.orig/pydantic/_internal/_generate_schema.py +++ pydantic-2.11.7/pydantic/_internal/_generate_schema.py @@ -42,6 +42,7 @@ from zoneinfo import ZoneInfo import typing_extensions from pydantic_core import ( + MISSING, CoreSchema, MultiHostUrl, PydanticCustomError, @@ -1050,6 +1051,8 @@ class GenerateSchema: return core_schema.multi_host_url_schema() elif obj is None or obj is _typing_extra.NoneType: return core_schema.none_schema() + if obj is MISSING: + return core_schema.missing_sentinel_schema() elif obj in IP_TYPES: return self._ip_schema(obj) elif obj in TUPLE_TYPES: Index: pydantic-2.11.7/pydantic/fields.py =================================================================== --- pydantic-2.11.7.orig/pydantic/fields.py +++ pydantic-2.11.7/pydantic/fields.py @@ -15,7 +15,7 @@ from warnings import warn import annotated_types import typing_extensions -from pydantic_core import PydanticUndefined +from pydantic_core import MISSING, PydanticUndefined from typing_extensions import Self, TypeAlias, Unpack, deprecated from typing_inspection import typing_objects from typing_inspection.introspection import UNKNOWN, AnnotationSource, ForbiddenQualifier, Qualifier, inspect_annotation @@ -392,7 +392,7 @@ class FieldInfo(_repr.Representation): Returns: A field object with the passed values. """ - if annotation is default: + if annotation is not MISSING and annotation is default: raise PydanticUserError( 'Error when building FieldInfo from annotated attribute. ' "Make sure you don't have any field name clashing with a type annotation.", Index: pydantic-2.11.7/pydantic/json_schema.py =================================================================== --- pydantic-2.11.7.orig/pydantic/json_schema.py +++ pydantic-2.11.7/pydantic/json_schema.py @@ -36,7 +36,7 @@ from typing import ( ) import pydantic_core -from pydantic_core import CoreSchema, PydanticOmit, core_schema, to_jsonable_python +from pydantic_core import MISSING, CoreSchema, PydanticOmit, core_schema, to_jsonable_python from pydantic_core.core_schema import ComputedField from typing_extensions import TypeAlias, assert_never, deprecated, final from typing_inspection.introspection import get_literal_values @@ -805,6 +805,17 @@ class GenerateJsonSchema: result['type'] = 'null' return result + def missing_sentinel_schema(self, schema: core_schema.MissingSentinelSchema) -> JsonSchemaValue: + """Generates a JSON schema that matches the `MISSING` sentinel value. + + Args: + schema: The core schema. + + Returns: + The generated JSON schema. + """ + raise PydanticOmit + def enum_schema(self, schema: core_schema.EnumSchema) -> JsonSchemaValue: """Generates a JSON schema that matches an Enum value. @@ -1109,7 +1120,7 @@ class GenerateJsonSchema: json_schema = self.generate_inner(schema['schema']) default = self.get_default_value(schema) - if default is NoDefault: + if default is NoDefault or default is MISSING: return json_schema # we reflect the application of custom plain, no-info serializers to defaults for Index: pydantic-2.11.7/pydantic/version.py =================================================================== --- pydantic-2.11.7.orig/pydantic/version.py +++ pydantic-2.11.7/pydantic/version.py @@ -66,7 +66,7 @@ def version_info() -> str: def check_pydantic_core_version() -> bool: """Check that the installed `pydantic-core` dependency is compatible.""" # Keep this in sync with the version constraint in the `pyproject.toml` dependencies: - return __pydantic_core_version__ == '2.35.1' + return __pydantic_core_version__ == '2.39.0' def parse_mypy_version(version: str) -> tuple[int, int, int]: Index: pydantic-2.11.7/docs/concepts/experimental.md =================================================================== --- pydantic-2.11.7.orig/docs/concepts/experimental.md +++ pydantic-2.11.7/docs/concepts/experimental.md @@ -502,3 +502,49 @@ args, kwargs = val.validate_json('{"args print(args, kwargs) #> ('arg1',) {'extra': 1} ``` + +## `MISSING` sentinel + +The `MISSING` sentinel is a singleton indicating a field value was not provided during validation. + +This singleton can be used as a default value, as an alternative to `None` when it has an explicit +meaning. During serialization, any field with `MISSING` as a value is excluded from the output. + +```python +from typing import Union + +from pydantic import BaseModel +from pydantic.experimental.missing_sentinel import MISSING + + +class Configuration(BaseModel): + timeout: Union[int, None, MISSING] = MISSING + + +# configuration defaults, stored somewhere else: +defaults = {'timeout': 200} + +conf = Configuration() + +# `timeout` is excluded from the serialization output: +conf.model_dump() +# {} + +# The `MISSING` value doesn't appear in the JSON Schema: +Configuration.model_json_schema()['properties']['timeout'] +#> {'anyOf': [{'type': 'integer'}, {'type': 'null'}], 'title': 'Timeout'}} + + +# `is` can be used to discrimate between the sentinel and other values: +timeout = conf.timeout if conf.timeout is not MISSING else defaults['timeout'] +``` + +This feature is marked as experimental because it relies on the draft [PEP 661](https://peps.python.org/pep-0661/), introducing sentinels in the standard library. + +As such, the following limitations currently apply: + +* Static type checking of sentinels is only supported with Pyright + [1.1.402](https://github.com/microsoft/pyright/releases/tag/1.1.402) + or greater, and the `enableExperimentalFeatures` type evaluation setting + should be enabled. +* Pickling of models containing `MISSING` as a value is not supported. Index: pydantic-2.11.7/docs/errors/validation_errors.md =================================================================== --- pydantic-2.11.7.orig/docs/errors/validation_errors.md +++ pydantic-2.11.7/docs/errors/validation_errors.md @@ -1384,6 +1384,27 @@ except ValidationError as exc: #> 'missing_positional_only_argument' ``` +## `missing_sentinel_error` + +This error is raised when the experimental `MISSING` sentinel is the only value allowed, and wasn't +provided during validation: + +```python +from pydantic import BaseModel, ValidationError +from pydantic.experimental.missing_sentinel import MISSING + + +class Model(BaseModel): + f: MISSING + + +try: + Model(f=1) +except ValidationError as exc: + print(repr(exc.errors()[0]['type'])) + #> 'missing_sentinel_error' +``` + ## `model_attributes_type` This error is raised when the input value is not a valid dictionary, model instance, or instance that fields can be extracted from: Index: pydantic-2.11.7/pydantic/experimental/missing_sentinel.py =================================================================== --- /dev/null +++ pydantic-2.11.7/pydantic/experimental/missing_sentinel.py @@ -0,0 +1,5 @@ +"""Experimental module exposing a function a `MISSING` sentinel.""" + +from pydantic_core import MISSING + +__all__ = ('MISSING',) Index: pydantic-2.11.7/pyproject.toml =================================================================== --- pydantic-2.11.7.orig/pyproject.toml +++ pydantic-2.11.7/pyproject.toml @@ -46,7 +46,7 @@ dependencies = [ 'typing-extensions>=4.13.0', 'annotated-types>=0.6.0', # Keep this in sync with the version in the `check_pydantic_core_version()` function: - 'pydantic-core==2.35.1', + 'pydantic-core==2.39.0', 'typing-inspection>=0.4.0', ] dynamic = ['version', 'readme'] Index: pydantic-2.11.7/tests/test_missing_sentinel.py =================================================================== --- /dev/null +++ pydantic-2.11.7/tests/test_missing_sentinel.py @@ -0,0 +1,71 @@ +import pickle +from typing import Union + +import pytest +from pydantic_core import MISSING, PydanticSerializationUnexpectedValue + +from pydantic import BaseModel, TypeAdapter, ValidationError + + +def test_missing_sentinel_model() -> None: + class Model(BaseModel): + f: Union[int, MISSING] = MISSING + g: MISSING = MISSING + + m1 = Model() + + assert m1.model_dump() == {} + assert m1.model_dump_json() == '{}' + + m2 = Model.model_validate({'f': MISSING, 'g': MISSING}) + + assert m2.f is MISSING + assert m2.g is MISSING + + m3 = Model(f=1) + + assert m3.model_dump() == {'f': 1} + assert m3.model_dump_json() == '{"f":1}' + + +def test_missing_sentinel_type_adapter() -> None: + """Note that this usage isn't explicitly supported (and useless in practice).""" + + # TODO Remove annotation with PEP 747: + ta: TypeAdapter[object] = TypeAdapter(MISSING) + + assert ta.validate_python(MISSING) is MISSING + + with pytest.raises(ValidationError) as exc_info: + ta.validate_python(1) + + assert exc_info.value.errors()[0]['type'] == 'missing_sentinel_error' + + assert ta.dump_python(MISSING) is MISSING + + with pytest.raises(PydanticSerializationUnexpectedValue): + ta.dump_python(1) + + +# Defined in module to be picklable: +class ModelPickle(BaseModel): + f: Union[int, MISSING] = MISSING + + +@pytest.mark.xfail(reason="PEP 661 sentinels aren't picklable yet in the experimental typing-extensions implementation") +def test_missing_sentinel_pickle() -> None: + m = ModelPickle() + m_reconstructed = pickle.loads(pickle.dumps(m)) + + assert m_reconstructed.f is MISSING + + +def test_missing_sentinel_json_schema() -> None: + class Model(BaseModel): + f: Union[int, MISSING] = MISSING + g: MISSING = MISSING + h: MISSING + + assert Model.model_json_schema()['properties'] == { + 'f': {'title': 'F', 'type': 'integer'}, + }