diff --git a/pyproject.toml b/pyproject.toml index d09c62a10..04eb87c9e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,9 @@ classifiers = [ 'Operating System :: MacOS', 'Typing :: Typed', ] -dependencies = ['typing-extensions>=4.6.0,!=4.7.0'] +dependencies = [ + 'typing-extensions@git+https://github.com/python/typing_extensions', +] dynamic = ['description', 'license', 'readme', 'version'] [project.urls] diff --git a/python/pydantic_core/__init__.py b/python/pydantic_core/__init__.py index 98b64b86e..0b0bce608 100644 --- a/python/pydantic_core/__init__.py +++ b/python/pydantic_core/__init__.py @@ -3,6 +3,8 @@ import sys as _sys from typing import Any as _Any +from typing_extensions import Sentinel + from ._pydantic_core import ( ArgsKwargs, MultiHostUrl, @@ -41,6 +43,7 @@ __all__ = [ '__version__', + 'UNSET', 'CoreConfig', 'CoreSchema', 'CoreSchemaType', @@ -142,3 +145,28 @@ class MultiHostHost(_TypedDict): """The host part of this host, or `None`.""" port: int | None """The port part of this host, or `None`.""" + + +UNSET = Sentinel('UNSET') +"""A singleton indicating a field value was not set during validation. + +This singleton can be used a default value, as an alternative to `None` when it has +an explicit meaning. During serialization, any field with `UNSET` as a value is excluded +from the output. + +Example: + ```python + from pydantic import BaseModel + from pydantic.experimental.unset import UNSET + + + class Configuration(BaseModel): + timeout: int | None | UNSET = UNSET + + + # configuration defaults, stored somewhere else: + defaults = {'timeout': 200} + + conf = Configuration.model_validate({...}) + timeout = conf.timeout if timeout.timeout is not UNSET else defaults['timeout'] +""" diff --git a/src/serializers/computed_fields.rs b/src/serializers/computed_fields.rs index 6e6786d73..5f051bd39 100644 --- a/src/serializers/computed_fields.rs +++ b/src/serializers/computed_fields.rs @@ -8,7 +8,9 @@ use crate::build_tools::py_schema_error_type; use crate::definitions::DefinitionsBuilder; use crate::py_gc::PyGcTraverse; use crate::serializers::filter::SchemaFilter; -use crate::serializers::shared::{BuildSerializer, CombinedSerializer, PydanticSerializer, TypeSerializer}; +use crate::serializers::shared::{ + get_unset_sentinel_object, BuildSerializer, CombinedSerializer, PydanticSerializer, TypeSerializer, +}; use crate::tools::SchemaDict; use super::errors::py_err_se_err; @@ -87,6 +89,10 @@ impl ComputedFields { if extra.exclude_none && value.is_none() { continue; } + let unset_obj = get_unset_sentinel_object(model.py()); + if value.is(unset_obj) { + continue; + } let field_extra = Extra { field_name: Some(computed_field.property_name.as_str()), ..*extra @@ -165,6 +171,10 @@ impl ComputedField { if extra.exclude_none && value.is_none(py) { return Ok(()); } + let unset_obj = get_unset_sentinel_object(model.py()); + if value.is(unset_obj) { + return Ok(()); + } let key = match extra.serialize_by_alias_or(self.serialize_by_alias) { true => self.alias_py.bind(py), false => property_name_py, diff --git a/src/serializers/fields.rs b/src/serializers/fields.rs index d4bc8bb67..685216f09 100644 --- a/src/serializers/fields.rs +++ b/src/serializers/fields.rs @@ -16,8 +16,7 @@ use super::errors::py_err_se_err; use super::extra::Extra; use super::filter::SchemaFilter; use super::infer::{infer_json_key, infer_serialize, infer_to_python, SerializeInfer}; -use super::shared::PydanticSerializer; -use super::shared::{CombinedSerializer, TypeSerializer}; +use super::shared::{get_unset_sentinel_object, CombinedSerializer, PydanticSerializer, TypeSerializer}; /// representation of a field for serialization #[derive(Debug)] @@ -155,6 +154,7 @@ impl GeneralFieldsSerializer { ) -> PyResult> { let output_dict = PyDict::new(py); let mut used_req_fields: usize = 0; + let unset_obj = get_unset_sentinel_object(py); // NOTE! we maintain the order of the input dict assuming that's right for result in main_iter { @@ -164,6 +164,10 @@ impl GeneralFieldsSerializer { if extra.exclude_none && value.is_none() { continue; } + if value.is(unset_obj) { + continue; + } + let field_extra = Extra { field_name: Some(key_str), ..extra @@ -239,9 +243,13 @@ impl GeneralFieldsSerializer { for result in main_iter { let (key, value) = result.map_err(py_err_se_err)?; + let unset_obj = get_unset_sentinel_object(value.py()); if extra.exclude_none && value.is_none() { continue; } + if value.is(unset_obj) { + continue; + } let key_str = key_str(&key).map_err(py_err_se_err)?; let field_extra = Extra { field_name: Some(key_str), @@ -327,6 +335,7 @@ impl TypeSerializer for GeneralFieldsSerializer { extra: &Extra, ) -> PyResult { let py = value.py(); + let unset_obj = get_unset_sentinel_object(py); // If there is already a model registered (from a dataclass, BaseModel) // then do not touch it // If there is no model, we (a TypedDict) are the model @@ -362,6 +371,9 @@ impl TypeSerializer for GeneralFieldsSerializer { if extra.exclude_none && value.is_none() { continue; } + if value.is(unset_obj) { + continue; + } if let Some((next_include, next_exclude)) = self.filter.key_filter(&key, include, exclude)? { let value = match &self.extra_serializer { Some(serializer) => { @@ -395,7 +407,7 @@ impl TypeSerializer for GeneralFieldsSerializer { extra.warnings.on_fallback_ser::(self.get_name(), value, extra)?; return infer_serialize(value, serializer, include, exclude, extra); }; - + let unset_obj = get_unset_sentinel_object(value.py()); // If there is already a model registered (from a dataclass, BaseModel) // then do not touch it // If there is no model, we (a TypedDict) are the model @@ -436,6 +448,9 @@ impl TypeSerializer for GeneralFieldsSerializer { if extra.exclude_none && value.is_none() { continue; } + if value.is(unset_obj) { + continue; + } let filter = self.filter.key_filter(&key, include, exclude).map_err(py_err_se_err)?; if let Some((next_include, next_exclude)) = filter { let output_key = infer_json_key(&key, extra).map_err(py_err_se_err)?; diff --git a/src/serializers/shared.rs b/src/serializers/shared.rs index e28ae9cee..908262cd6 100644 --- a/src/serializers/shared.rs +++ b/src/serializers/shared.rs @@ -33,6 +33,19 @@ pub(crate) trait BuildSerializer: Sized { ) -> PyResult; } +static UNSET_SENTINEL_OBJECT: GILOnceCell> = GILOnceCell::new(); + +pub fn get_unset_sentinel_object(py: Python) -> &Bound<'_, PyAny> { + UNSET_SENTINEL_OBJECT + .get_or_init(py, || { + py.import(intern!(py, "pydantic_core")) + .and_then(|core_module| core_module.getattr(intern!(py, "UNSET"))) + .unwrap() + .into() + }) + .bind(py) +} + /// Build the `CombinedSerializer` enum and implement a `find_serializer` method for it. macro_rules! combined_serializer { ( diff --git a/uv.lock b/uv.lock index 717abdced..a14bb0e55 100644 --- a/uv.lock +++ b/uv.lock @@ -636,7 +636,7 @@ wasm = [ ] [package.metadata] -requires-dist = [{ name = "typing-extensions", specifier = ">=4.6.0,!=4.7.0" }] +requires-dist = [{ name = "typing-extensions", git = "https://github.com/python/typing_extensions" }] [package.metadata.requires-dev] all = [ @@ -957,12 +957,8 @@ wheels = [ [[package]] name = "typing-extensions" -version = "4.12.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321, upload-time = "2024-06-07T18:52:15.995Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438, upload-time = "2024-06-07T18:52:13.582Z" }, -] +version = "4.13.2" +source = { git = "https://github.com/python/typing_extensions#479dae13d084c070301aa91265d1af278b181457" } [[package]] name = "tzdata"