From a43c63454b50fdd334b3fa1b216e87c2e472b9e6 Mon Sep 17 00:00:00 2001 From: Viicos <65306057+Viicos@users.noreply.github.com> Date: Fri, 16 May 2025 15:35:32 +0200 Subject: [PATCH 1/9] Add `UNSET` sentinel --- pyproject.toml | 2 +- python/pydantic_core/__init__.py | 28 ++++++++++++++++++++++++++++ src/serializers/computed_fields.rs | 6 +++++- src/serializers/fields.rs | 21 ++++++++++++++++++--- src/serializers/shared.rs | 13 +++++++++++++ uv.lock | 10 +++------- 6 files changed, 68 insertions(+), 12 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index fb00a36a5..26c6083d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ classifiers = [ 'Typing :: Typed', ] dependencies = [ - 'typing-extensions>=4.13.0', + 'typing-extensions@git+https://github.com/python/typing_extensions', ] dynamic = ['license', 'readme', 'version'] diff --git a/python/pydantic_core/__init__.py b/python/pydantic_core/__init__.py index a146499d0..f186e402c 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, @@ -40,6 +42,7 @@ __all__ = [ '__version__', + 'UNSET', 'CoreConfig', 'CoreSchema', 'CoreSchemaType', @@ -140,3 +143,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 7a574093c..7ab00e108 100644 --- a/src/serializers/computed_fields.rs +++ b/src/serializers/computed_fields.rs @@ -7,7 +7,7 @@ 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}; +use crate::serializers::shared::{get_unset_sentinel_object, BuildSerializer, CombinedSerializer, PydanticSerializer}; use crate::tools::SchemaDict; use super::errors::py_err_se_err; @@ -148,6 +148,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), diff --git a/src/serializers/fields.rs b/src/serializers/fields.rs index 44e6f3c7b..44057a39a 100644 --- a/src/serializers/fields.rs +++ b/src/serializers/fields.rs @@ -15,8 +15,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)] @@ -169,6 +168,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 { @@ -178,6 +178,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 @@ -253,9 +257,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), @@ -347,6 +355,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 @@ -368,6 +377,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) => { @@ -401,7 +413,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 @@ -428,6 +440,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 81a673dde..1063f697a 100644 --- a/src/serializers/shared.rs +++ b/src/serializers/shared.rs @@ -34,6 +34,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 de11e849a..9cf550181 100644 --- a/uv.lock +++ b/uv.lock @@ -636,7 +636,7 @@ wasm = [ ] [package.metadata] -requires-dist = [{ name = "typing-extensions", specifier = ">=4.13.0" }] +requires-dist = [{ name = "typing-extensions", git = "https://github.com/python/typing_extensions" }] [package.metadata.requires-dev] all = [ @@ -956,12 +956,8 @@ wheels = [ [[package]] name = "typing-extensions" -version = "4.14.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d1/bc/51647cd02527e87d05cb083ccc402f93e441606ff1f01739a62c8ad09ba5/typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4", size = 107423, upload-time = "2025-06-02T14:52:11.399Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/69/e0/552843e0d356fbb5256d21449fa957fa4eff3bbc135a74a691ee70c7c5da/typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af", size = 43839, upload-time = "2025-06-02T14:52:10.026Z" }, -] +version = "4.13.2" +source = { git = "https://github.com/python/typing_extensions#479dae13d084c070301aa91265d1af278b181457" } [[package]] name = "typing-inspection" From 526955bb639d64b80519e7164680e23d958537fe Mon Sep 17 00:00:00 2001 From: Viicos <65306057+Viicos@users.noreply.github.com> Date: Tue, 1 Jul 2025 16:51:12 +0200 Subject: [PATCH 2/9] Use updated te branch, add core schema --- pyproject.toml | 2 +- python/pydantic_core/__init__.py | 2 +- python/pydantic_core/core_schema.py | 10 ++++++++++ uv.lock | 6 +++--- 4 files changed, 15 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 26c6083d0..fe76ee5e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ classifiers = [ 'Typing :: Typed', ] dependencies = [ - 'typing-extensions@git+https://github.com/python/typing_extensions', + 'typing-extensions@git+https://github.com/HexDecimal/typing_extensions@conforming-sentinel', ] dynamic = ['license', 'readme', 'version'] diff --git a/python/pydantic_core/__init__.py b/python/pydantic_core/__init__.py index f186e402c..aed27b1ad 100644 --- a/python/pydantic_core/__init__.py +++ b/python/pydantic_core/__init__.py @@ -145,7 +145,7 @@ class MultiHostHost(_TypedDict): """The port part of this host, or `None`.""" -UNSET = Sentinel('UNSET') +UNSET = Sentinel('UNSET', module_name='pydantic_core') """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 diff --git a/python/pydantic_core/core_schema.py b/python/pydantic_core/core_schema.py index 7e0cd7e4c..34b699d4c 100644 --- a/python/pydantic_core/core_schema.py +++ b/python/pydantic_core/core_schema.py @@ -1346,6 +1346,16 @@ class Color(Enum): ) +class UnsetSentinelSchema(TypedDict, total=False): + type: Required[Literal['unset-sentinel']] + + +def unset_sentinel_schema() -> UnsetSentinelSchema: + """Returns a schema for the [`UNSET`][pydantic_core.UNSET] sentinel.""" + + return {'type': 'unset-sentinel'} + + # must match input/parse_json.rs::JsonType::try_from JsonType = Literal['null', 'bool', 'int', 'float', 'str', 'list', 'dict'] diff --git a/uv.lock b/uv.lock index 9cf550181..24c891eb7 100644 --- a/uv.lock +++ b/uv.lock @@ -636,7 +636,7 @@ wasm = [ ] [package.metadata] -requires-dist = [{ name = "typing-extensions", git = "https://github.com/python/typing_extensions" }] +requires-dist = [{ name = "typing-extensions", git = "https://github.com/HexDecimal/typing_extensions?rev=conforming-sentinel" }] [package.metadata.requires-dev] all = [ @@ -956,8 +956,8 @@ wheels = [ [[package]] name = "typing-extensions" -version = "4.13.2" -source = { git = "https://github.com/python/typing_extensions#479dae13d084c070301aa91265d1af278b181457" } +version = "4.14.0" +source = { git = "https://github.com/HexDecimal/typing_extensions?rev=conforming-sentinel#5c0e8a79317478ebfda949c56b011be47a618eb0" } [[package]] name = "typing-inspection" From f4ccac25e3a950df3692db5a1b02f2483279fa11 Mon Sep 17 00:00:00 2001 From: Viicos <65306057+Viicos@users.noreply.github.com> Date: Wed, 2 Jul 2025 12:47:44 +0200 Subject: [PATCH 3/9] Add unset-sentinel validator/serializer --- python/pydantic_core/core_schema.py | 2 + src/common/mod.rs | 1 + src/common/unset_sentinel.rs | 16 ++++ src/errors/types.rs | 3 + src/serializers/computed_fields.rs | 3 +- src/serializers/fields.rs | 3 +- src/serializers/shared.rs | 15 +--- src/serializers/type_serializers/mod.rs | 1 + .../type_serializers/unset_sentinel.rs | 76 +++++++++++++++++++ src/validators/mod.rs | 5 ++ src/validators/unset_sentinel.rs | 47 ++++++++++++ 11 files changed, 157 insertions(+), 15 deletions(-) create mode 100644 src/common/unset_sentinel.rs create mode 100644 src/serializers/type_serializers/unset_sentinel.rs create mode 100644 src/validators/unset_sentinel.rs diff --git a/python/pydantic_core/core_schema.py b/python/pydantic_core/core_schema.py index 34b699d4c..9f44405e8 100644 --- a/python/pydantic_core/core_schema.py +++ b/python/pydantic_core/core_schema.py @@ -4092,6 +4092,7 @@ def definition_reference_schema( DatetimeSchema, TimedeltaSchema, LiteralSchema, + UnsetSentinelSchema, EnumSchema, IsInstanceSchema, IsSubclassSchema, @@ -4150,6 +4151,7 @@ def definition_reference_schema( 'datetime', 'timedelta', 'literal', + 'unset-sentinel', 'enum', 'is-instance', 'is-subclass', diff --git a/src/common/mod.rs b/src/common/mod.rs index 47c0a0349..20ad896b0 100644 --- a/src/common/mod.rs +++ b/src/common/mod.rs @@ -1,2 +1,3 @@ pub(crate) mod prebuilt; pub(crate) mod union; +pub(crate) mod unset_sentinel; diff --git a/src/common/unset_sentinel.rs b/src/common/unset_sentinel.rs new file mode 100644 index 000000000..f34d172ed --- /dev/null +++ b/src/common/unset_sentinel.rs @@ -0,0 +1,16 @@ +use pyo3::intern; +use pyo3::prelude::*; +use pyo3::sync::GILOnceCell; + +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) +} diff --git a/src/errors/types.rs b/src/errors/types.rs index 0c75e1e24..c85d9253f 100644 --- a/src/errors/types.rs +++ b/src/errors/types.rs @@ -316,6 +316,8 @@ error_types! { expected: {ctx_type: String, ctx_fn: field_from_context}, }, // --------------------- + // unset sentinel + UnsetSentinelError {}, // date errors DateType {}, DateParsing { @@ -531,6 +533,7 @@ impl ErrorType { Self::AssertionError {..} => "Assertion failed, {error}", Self::CustomError {..} => "", // custom errors are handled separately Self::LiteralError {..} => "Input should be {expected}", + Self::UnsetSentinelError { .. } => "Input should be the 'UNSET' sentinel", Self::DateType {..} => "Input should be a valid date", Self::DateParsing {..} => "Input should be a valid date in the format YYYY-MM-DD, {error}", Self::DateFromDatetimeParsing {..} => "Input should be a valid date or datetime, {error}", diff --git a/src/serializers/computed_fields.rs b/src/serializers/computed_fields.rs index 7ab00e108..8867f1920 100644 --- a/src/serializers/computed_fields.rs +++ b/src/serializers/computed_fields.rs @@ -4,10 +4,11 @@ use pyo3::{intern, PyTraverseError, PyVisit}; use serde::ser::SerializeMap; use crate::build_tools::py_schema_error_type; +use crate::common::unset_sentinel::get_unset_sentinel_object; use crate::definitions::DefinitionsBuilder; use crate::py_gc::PyGcTraverse; use crate::serializers::filter::SchemaFilter; -use crate::serializers::shared::{get_unset_sentinel_object, BuildSerializer, CombinedSerializer, PydanticSerializer}; +use crate::serializers::shared::{BuildSerializer, CombinedSerializer, PydanticSerializer}; use crate::tools::SchemaDict; use super::errors::py_err_se_err; diff --git a/src/serializers/fields.rs b/src/serializers/fields.rs index 44057a39a..2cac7d789 100644 --- a/src/serializers/fields.rs +++ b/src/serializers/fields.rs @@ -7,6 +7,7 @@ use ahash::AHashMap; use serde::ser::SerializeMap; use smallvec::SmallVec; +use crate::common::unset_sentinel::get_unset_sentinel_object; use crate::serializers::extra::SerCheck; use crate::PydanticSerializationUnexpectedValue; @@ -15,7 +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::{get_unset_sentinel_object, CombinedSerializer, PydanticSerializer, TypeSerializer}; +use super::shared::{CombinedSerializer, PydanticSerializer, TypeSerializer}; /// representation of a field for serialization #[derive(Debug)] diff --git a/src/serializers/shared.rs b/src/serializers/shared.rs index 1063f697a..9cdcc193b 100644 --- a/src/serializers/shared.rs +++ b/src/serializers/shared.rs @@ -34,19 +34,6 @@ 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 { ( @@ -155,6 +142,7 @@ combined_serializer! { Union: super::type_serializers::union::UnionSerializer; TaggedUnion: super::type_serializers::union::TaggedUnionSerializer; Literal: super::type_serializers::literal::LiteralSerializer; + UnsetSentinel: super::type_serializers::unset_sentinel::UnsetSentinelSerializer; Enum: super::type_serializers::enum_::EnumSerializer; Recursive: super::type_serializers::definitions::DefinitionRefSerializer; Tuple: super::type_serializers::tuple::TupleSerializer; @@ -356,6 +344,7 @@ impl PyGcTraverse for CombinedSerializer { CombinedSerializer::Union(inner) => inner.py_gc_traverse(visit), CombinedSerializer::TaggedUnion(inner) => inner.py_gc_traverse(visit), CombinedSerializer::Literal(inner) => inner.py_gc_traverse(visit), + CombinedSerializer::UnsetSentinel(inner) => inner.py_gc_traverse(visit), CombinedSerializer::Enum(inner) => inner.py_gc_traverse(visit), CombinedSerializer::Recursive(inner) => inner.py_gc_traverse(visit), CombinedSerializer::Tuple(inner) => inner.py_gc_traverse(visit), diff --git a/src/serializers/type_serializers/mod.rs b/src/serializers/type_serializers/mod.rs index dabd006a3..9aefdfe59 100644 --- a/src/serializers/type_serializers/mod.rs +++ b/src/serializers/type_serializers/mod.rs @@ -25,6 +25,7 @@ pub mod timedelta; pub mod tuple; pub mod typed_dict; pub mod union; +pub mod unset_sentinel; pub mod url; pub mod uuid; pub mod with_default; diff --git a/src/serializers/type_serializers/unset_sentinel.rs b/src/serializers/type_serializers/unset_sentinel.rs new file mode 100644 index 000000000..13c794696 --- /dev/null +++ b/src/serializers/type_serializers/unset_sentinel.rs @@ -0,0 +1,76 @@ +// This serializer is defined so that building a schema serializer containing an +// 'unset-sentinel' core schema doesn't crash. In practice, the serializer isn't +// used for model-like classes, as the 'fields' serializer takes care of omitting +// the fields from the output (the serializer can still be used if the 'unset-sentinel' +// core schema is used standalone (e.g. with a Pydantic type adapter), but this isn't +// something we explicitly support. + +use std::borrow::Cow; + +use pyo3::prelude::*; +use pyo3::types::PyDict; + +use serde::ser::Error; + +use crate::common::unset_sentinel::get_unset_sentinel_object; +use crate::definitions::DefinitionsBuilder; +use crate::PydanticSerializationUnexpectedValue; + +use super::{BuildSerializer, CombinedSerializer, Extra, TypeSerializer}; + +#[derive(Debug)] +pub struct UnsetSentinelSerializer {} + +impl BuildSerializer for UnsetSentinelSerializer { + const EXPECTED_TYPE: &'static str = "unset-sentinel"; + + fn build( + _schema: &Bound<'_, PyDict>, + _config: Option<&Bound<'_, PyDict>>, + _definitions: &mut DefinitionsBuilder, + ) -> PyResult { + Ok(Self {}.into()) + } +} + +impl_py_gc_traverse!(UnsetSentinelSerializer {}); + +impl TypeSerializer for UnsetSentinelSerializer { + fn to_python( + &self, + value: &Bound<'_, PyAny>, + _include: Option<&Bound<'_, PyAny>>, + _exclude: Option<&Bound<'_, PyAny>>, + _extra: &Extra, + ) -> PyResult { + let unset_obj = get_unset_sentinel_object(value.py()); + + if value.is(unset_obj) { + Ok(unset_obj.to_owned().into()) + } else { + Err( + PydanticSerializationUnexpectedValue::new_from_msg(Some("Expected 'UNSET' sentinel".to_string())) + .to_py_err(), + ) + } + } + + fn json_key<'a>(&self, key: &'a Bound<'_, PyAny>, extra: &Extra) -> PyResult> { + self.invalid_as_json_key(key, extra, Self::EXPECTED_TYPE) + } + + fn serde_serialize( + &self, + _value: &Bound<'_, PyAny>, + _serializer: S, + _include: Option<&Bound<'_, PyAny>>, + _exclude: Option<&Bound<'_, PyAny>>, + _extra: &Extra, + ) -> Result { + Err(Error::custom("'UNSET' can't be serialized to JSON".to_string())) + } + + fn get_name(&self) -> &str { + Self::EXPECTED_TYPE + } +} diff --git a/src/validators/mod.rs b/src/validators/mod.rs index 2fd79c495..8f06af057 100644 --- a/src/validators/mod.rs +++ b/src/validators/mod.rs @@ -59,6 +59,7 @@ mod timedelta; mod tuple; mod typed_dict; mod union; +mod unset_sentinel; mod url; mod uuid; mod validation_state; @@ -574,6 +575,8 @@ fn build_validator_inner( call::CallValidator, // literals literal::LiteralValidator, + // unset sentinel + unset_sentinel::UnsetSentinelValidator, // enums enum_::BuildEnumValidator, // any @@ -741,6 +744,8 @@ pub enum CombinedValidator { FunctionCall(call::CallValidator), // literals Literal(literal::LiteralValidator), + // Unset sentinel + UnsetSentinel(unset_sentinel::UnsetSentinelValidator), // enums IntEnum(enum_::EnumValidator), StrEnum(enum_::EnumValidator), diff --git a/src/validators/unset_sentinel.rs b/src/validators/unset_sentinel.rs new file mode 100644 index 000000000..2b98283cb --- /dev/null +++ b/src/validators/unset_sentinel.rs @@ -0,0 +1,47 @@ +use core::fmt::Debug; + +use pyo3::prelude::*; +use pyo3::types::PyDict; + +use crate::common::unset_sentinel::get_unset_sentinel_object; +use crate::errors::{ErrorType, ValError, ValResult}; +use crate::input::Input; + +use super::{BuildValidator, CombinedValidator, DefinitionsBuilder, ValidationState, Validator}; + +#[derive(Debug, Clone)] +pub struct UnsetSentinelValidator {} + +impl BuildValidator for UnsetSentinelValidator { + const EXPECTED_TYPE: &'static str = "unset-sentinel"; + + fn build( + _schema: &Bound<'_, PyDict>, + _config: Option<&Bound<'_, PyDict>>, + _definitions: &mut DefinitionsBuilder, + ) -> PyResult { + Ok(CombinedValidator::UnsetSentinel(Self {})) + } +} + +impl_py_gc_traverse!(UnsetSentinelValidator {}); + +impl Validator for UnsetSentinelValidator { + fn validate<'py>( + &self, + py: Python<'py>, + input: &(impl Input<'py> + ?Sized), + _state: &mut ValidationState<'_, 'py>, + ) -> ValResult { + let unset_obj = get_unset_sentinel_object(py); + + match input.as_python() { + Some(v) if v.is(unset_obj) => Ok(v.to_owned().into()), + _ => Err(ValError::new(ErrorType::UnsetSentinelError { context: None }, input)), + } + } + + fn get_name(&self) -> &str { + Self::EXPECTED_TYPE + } +} From 7af4f9c71092d855563ae469ea0a51e5aceba73a Mon Sep 17 00:00:00 2001 From: Viicos <65306057+Viicos@users.noreply.github.com> Date: Fri, 25 Jul 2025 15:45:37 +0200 Subject: [PATCH 4/9] Fix tests --- python/pydantic_core/core_schema.py | 1 + tests/serializers/test_model.py | 28 ++++++++++++++ tests/test.rs | 59 ----------------------------- tests/test_errors.py | 1 + tests/test_schema_functions.py | 1 + 5 files changed, 31 insertions(+), 59 deletions(-) diff --git a/python/pydantic_core/core_schema.py b/python/pydantic_core/core_schema.py index 9f44405e8..9fe42509d 100644 --- a/python/pydantic_core/core_schema.py +++ b/python/pydantic_core/core_schema.py @@ -4251,6 +4251,7 @@ def definition_reference_schema( 'value_error', 'assertion_error', 'literal_error', + 'unset_sentinel_error', 'date_type', 'date_parsing', 'date_from_datetime_parsing', diff --git a/tests/serializers/test_model.py b/tests/serializers/test_model.py index 2a7b759a4..74712685f 100644 --- a/tests/serializers/test_model.py +++ b/tests/serializers/test_model.py @@ -744,6 +744,34 @@ def volume(self) -> int: assert s.to_json(Model(3, 4), by_alias=True) == b'{"width":3,"height":4,"Area":12,"volume":48}' +def test_computed_field_without_fields() -> None: + """https://github.com/pydantic/pydantic/issues/5551""" + + # Original test introduced in https://github.com/pydantic/pydantic-core/pull/550 + + class A: + @property + def b(self) -> str: + return 'b' + + schema = core_schema.model_schema( + cls=A, + config={}, + schema=core_schema.model_fields_schema( + fields={}, + computed_fields=[ + core_schema.computed_field('b', return_schema=core_schema.any_schema()), + ], + ), + ) + + a = A() + + serializer = SchemaSerializer(schema) + + assert serializer.to_json(a) == b'{"b":"b"}' + + def test_computed_field_exclude_none(): @dataclasses.dataclass class Model: diff --git a/tests/test.rs b/tests/test.rs index 6ca066c91..90f8062d3 100644 --- a/tests/test.rs +++ b/tests/test.rs @@ -53,65 +53,6 @@ mod tests { }); } - #[test] - fn test_serialize_computed_fields() { - Python::with_gil(|py| { - let code = c_str!( - r#" -class A: - @property - def b(self) -> str: - return "b" - -schema = { - "cls": A, - "config": {}, - "schema": { - "computed_fields": [ - {"property_name": "b", "return_schema": {"type": "any"}, "type": "computed-field"} - ], - "fields": {}, - "type": "model-fields", - }, - "type": "model", -} -a = A() - "# - ); - let locals = PyDict::new(py); - py.run(code, None, Some(&locals)).unwrap(); - let a = locals.get_item("a").unwrap().unwrap(); - let schema = locals - .get_item("schema") - .unwrap() - .unwrap() - .downcast_into::() - .unwrap(); - let serialized = SchemaSerializer::py_new(schema, None) - .unwrap() - .to_json( - py, - &a, - None, - Some(false), - None, - None, - Some(true), - false, - false, - false, - false, - WarningsArg::Bool(true), - None, - false, - None, - ) - .unwrap(); - let serialized: &[u8] = serialized.extract(py).unwrap(); - assert_eq!(serialized, b"{\"b\":\"b\"}"); - }); - } - #[test] fn test_literal_schema() { Python::with_gil(|py| { diff --git a/tests/test_errors.py b/tests/test_errors.py index ad18a41c8..8f5a198ed 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -340,6 +340,7 @@ def f(input_value, info): ('assertion_error', 'Assertion failed, foobar', {'error': AssertionError('foobar')}), ('literal_error', 'Input should be foo', {'expected': 'foo'}), ('literal_error', 'Input should be foo or bar', {'expected': 'foo or bar'}), + ('unset_sentinel_error', "Input should be the 'UNSET' sentinel", None), ('date_type', 'Input should be a valid date', None), ('date_parsing', 'Input should be a valid date in the format YYYY-MM-DD, foobar', {'error': 'foobar'}), ('date_from_datetime_parsing', 'Input should be a valid date or datetime, foobar', {'error': 'foobar'}), diff --git a/tests/test_schema_functions.py b/tests/test_schema_functions.py index c8a24b307..cf86c4489 100644 --- a/tests/test_schema_functions.py +++ b/tests/test_schema_functions.py @@ -82,6 +82,7 @@ def args(*args, **kwargs): {'type': 'timedelta', 'microseconds_precision': 'error'}, ), (core_schema.literal_schema, args(['a', 'b']), {'type': 'literal', 'expected': ['a', 'b']}), + (core_schema.unset_sentinel_schema, args(), {'type': 'unset-sentinel'}), ( core_schema.enum_schema, args(MyEnum, list(MyEnum.__members__.values())), From 757ea04d4483be393cd65fc36c8a135a3c687449 Mon Sep 17 00:00:00 2001 From: Viicos <65306057+Viicos@users.noreply.github.com> Date: Fri, 25 Jul 2025 16:28:38 +0200 Subject: [PATCH 5/9] Upgrade pyright --- pyproject.toml | 1 + uv.lock | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index fe76ee5e6..e99372666 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -160,3 +160,4 @@ fix = ["create", "fix"] # this ensures that `uv run` doesn't actually build the package; a `make` # command is needed to build package = false +required-version = '>=0.7.2' diff --git a/uv.lock b/uv.lock index 24c891eb7..d85397eff 100644 --- a/uv.lock +++ b/uv.lock @@ -708,15 +708,15 @@ wheels = [ [[package]] name = "pyright" -version = "1.1.392.post0" +version = "1.1.403" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nodeenv" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/66/df/3c6f6b08fba7ccf49b114dfc4bb33e25c299883fd763f93fad47ef8bc58d/pyright-1.1.392.post0.tar.gz", hash = "sha256:3b7f88de74a28dcfa90c7d90c782b6569a48c2be5f9d4add38472bdaac247ebd", size = 3789911, upload-time = "2025-01-15T15:01:20.913Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/f6/35f885264ff08c960b23d1542038d8da86971c5d8c955cfab195a4f672d7/pyright-1.1.403.tar.gz", hash = "sha256:3ab69b9f41c67fb5bbb4d7a36243256f0d549ed3608678d381d5f51863921104", size = 3913526, upload-time = "2025-07-09T07:15:52.882Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/b1/a18de17f40e4f61ca58856b9ef9b0febf74ff88978c3f7776f910071f567/pyright-1.1.392.post0-py3-none-any.whl", hash = "sha256:252f84458a46fa2f0fd4e2f91fc74f50b9ca52c757062e93f6c250c0d8329eb2", size = 5595487, upload-time = "2025-01-15T15:01:17.775Z" }, + { url = "https://files.pythonhosted.org/packages/49/b6/b04e5c2f41a5ccad74a1a4759da41adb20b4bc9d59a5e08d29ba60084d07/pyright-1.1.403-py3-none-any.whl", hash = "sha256:c0eeca5aa76cbef3fcc271259bbd785753c7ad7bcac99a9162b4c4c7daed23b3", size = 5684504, upload-time = "2025-07-09T07:15:50.958Z" }, ] [[package]] From 650e8ff6f7a9bddd9a4f03f8d79aab50c2f3730d Mon Sep 17 00:00:00 2001 From: Viicos <65306057+Viicos@users.noreply.github.com> Date: Fri, 25 Jul 2025 16:40:08 +0200 Subject: [PATCH 6/9] fix wasm tests --- tests/emscripten_runner.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/emscripten_runner.js b/tests/emscripten_runner.js index c527f86f8..64c459a4c 100644 --- a/tests/emscripten_runner.js +++ b/tests/emscripten_runner.js @@ -105,7 +105,7 @@ await micropip.install([ 'pytest-mock', 'tzdata', 'file:${wheel_path}', - 'typing-extensions', + 'typing-extensions>=4.14.1', 'typing-inspection', ]) importlib.invalidate_caches() From 09e257df0b1603b8fad85dfe16c1517f227348e9 Mon Sep 17 00:00:00 2001 From: Viicos <65306057+Viicos@users.noreply.github.com> Date: Fri, 25 Jul 2025 17:18:33 +0200 Subject: [PATCH 7/9] Update pyodide --- .github/workflows/ci.yml | 4 ++-- package.json | 2 +- wasm-preview/worker.js | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43b639cfa..0a6c5da0c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -329,8 +329,8 @@ jobs: - uses: mymindstorm/setup-emsdk@v14 with: # NOTE!: as per https://github.com/pydantic/pydantic-core/pull/149 this version needs to match the version - # in node_modules/pyodide/repodata.json, to get the version, run: - # `cat node_modules/pyodide/repodata.json | python -m json.tool | rg platform` + # in node_modules/pyodide/pyodide-lock.json, to get the version, run: + # `cat node_modules/pyodide/pyodide-lock.json | jq .info.platform` version: '3.1.58' actions-cache-folder: emsdk-cache diff --git a/package.json b/package.json index ecfc0372e..33a0a8d14 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "main": "tests/emscripten_runner.js", "dependencies": { "prettier": "^2.7.1", - "pyodide": "^0.26.3" + "pyodide": "^0.27.7" }, "scripts": { "test": "node tests/emscripten_runner.js", diff --git a/wasm-preview/worker.js b/wasm-preview/worker.js index 13414cdb5..f4c197952 100644 --- a/wasm-preview/worker.js +++ b/wasm-preview/worker.js @@ -89,7 +89,7 @@ async function main() { get(`./run_tests.py?v=${Date.now()}`, 'text'), // e4cf2e2 commit matches the pydantic-core wheel being used, so tests should pass get(zip_url, 'blob'), - importScripts('https://cdn.jsdelivr.net/pyodide/v0.26.3/full/pyodide.js'), + importScripts('https://cdn.jsdelivr.net/pyodide/v0.27.7/full/pyodide.js'), ]); const pyodide = await loadPyodide(); From 38bc22d5bf3ddc5dd0773cc53d6c65e96b54f8a5 Mon Sep 17 00:00:00 2001 From: Viicos <65306057+Viicos@users.noreply.github.com> Date: Fri, 25 Jul 2025 17:23:53 +0200 Subject: [PATCH 8/9] deps fixes --- pyproject.toml | 2 +- uv.lock | 10 +++++++--- wasm-preview/run_tests.py | 2 ++ wasm-preview/worker.js | 1 - 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e99372666..edb2d99c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ classifiers = [ 'Typing :: Typed', ] dependencies = [ - 'typing-extensions@git+https://github.com/HexDecimal/typing_extensions@conforming-sentinel', + 'typing-extensions>=4.14.1', ] dynamic = ['license', 'readme', 'version'] diff --git a/uv.lock b/uv.lock index d85397eff..b1da22c74 100644 --- a/uv.lock +++ b/uv.lock @@ -636,7 +636,7 @@ wasm = [ ] [package.metadata] -requires-dist = [{ name = "typing-extensions", git = "https://github.com/HexDecimal/typing_extensions?rev=conforming-sentinel" }] +requires-dist = [{ name = "typing-extensions", specifier = ">=4.14.1" }] [package.metadata.requires-dev] all = [ @@ -956,8 +956,12 @@ wheels = [ [[package]] name = "typing-extensions" -version = "4.14.0" -source = { git = "https://github.com/HexDecimal/typing_extensions?rev=conforming-sentinel#5c0e8a79317478ebfda949c56b011be47a618eb0" } +version = "4.14.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673, upload-time = "2025-07-04T13:28:34.16Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" }, +] [[package]] name = "typing-inspection" diff --git a/wasm-preview/run_tests.py b/wasm-preview/run_tests.py index f4a7b3f33..00b8dfc61 100644 --- a/wasm-preview/run_tests.py +++ b/wasm-preview/run_tests.py @@ -45,6 +45,8 @@ async def main(tests_zip: str, tag_name: str): 'pytest-mock', 'tzdata', 'inline-snapshot<0.21', + 'typing-extensions>=4.14.1', + 'typing-inspection', pydantic_core_wheel, ] ) diff --git a/wasm-preview/worker.js b/wasm-preview/worker.js index f4c197952..f6c72846d 100644 --- a/wasm-preview/worker.js +++ b/wasm-preview/worker.js @@ -98,7 +98,6 @@ async function main() { FS.mkdir('/test_dir'); FS.chdir('/test_dir'); await pyodide.loadPackage(['micropip', 'pytest', 'numpy', 'pygments']); - if (pydantic_core_version < '2.0.0') await pyodide.loadPackage(['typing-extensions']); await pyodide.runPythonAsync(python_code, {globals: pyodide.toPy({pydantic_core_version, tests_zip})}); post(); } catch (err) { From 471da62d23cd7c37b5dc6f112ccd0d857b908d6a Mon Sep 17 00:00:00 2001 From: Viicos <65306057+Viicos@users.noreply.github.com> Date: Fri, 25 Jul 2025 17:48:45 +0200 Subject: [PATCH 9/9] Rename to MISSING --- python/pydantic_core/__init__.py | 13 +++++----- python/pydantic_core/core_schema.py | 16 ++++++------ ...{unset_sentinel.rs => missing_sentinel.rs} | 8 +++--- src/common/mod.rs | 2 +- src/errors/types.rs | 6 ++--- src/serializers/computed_fields.rs | 6 ++--- src/serializers/fields.rs | 18 ++++++------- src/serializers/shared.rs | 4 +-- ...{unset_sentinel.rs => missing_sentinel.rs} | 26 +++++++++---------- src/serializers/type_serializers/mod.rs | 2 +- ...{unset_sentinel.rs => missing_sentinel.rs} | 20 +++++++------- src/validators/mod.rs | 10 +++---- tests/test_errors.py | 2 +- tests/test_schema_functions.py | 2 +- 14 files changed, 68 insertions(+), 67 deletions(-) rename src/common/{unset_sentinel.rs => missing_sentinel.rs} (60%) rename src/serializers/type_serializers/{unset_sentinel.rs => missing_sentinel.rs} (70%) rename src/validators/{unset_sentinel.rs => missing_sentinel.rs} (56%) diff --git a/python/pydantic_core/__init__.py b/python/pydantic_core/__init__.py index aed27b1ad..2dbd756f1 100644 --- a/python/pydantic_core/__init__.py +++ b/python/pydantic_core/__init__.py @@ -145,26 +145,27 @@ class MultiHostHost(_TypedDict): """The port part of this host, or `None`.""" -UNSET = Sentinel('UNSET', module_name='pydantic_core') -"""A singleton indicating a field value was not set during validation. +MISSING = Sentinel('MISSING') +"""A singleton indicating a field value was not provided 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 +an explicit meaning. During serialization, any field with `MISSING` as a value is excluded from the output. Example: ```python from pydantic import BaseModel - from pydantic.experimental.unset import UNSET + + from pydantic_core import MISSING class Configuration(BaseModel): - timeout: int | None | UNSET = UNSET + timeout: int | None | MISSING = MISSING # configuration defaults, stored somewhere else: defaults = {'timeout': 200} conf = Configuration.model_validate({...}) - timeout = conf.timeout if timeout.timeout is not UNSET else defaults['timeout'] + timeout = conf.timeout if timeout.timeout is not MISSING else defaults['timeout'] """ diff --git a/python/pydantic_core/core_schema.py b/python/pydantic_core/core_schema.py index 9fe42509d..0ede97911 100644 --- a/python/pydantic_core/core_schema.py +++ b/python/pydantic_core/core_schema.py @@ -1346,14 +1346,14 @@ class Color(Enum): ) -class UnsetSentinelSchema(TypedDict, total=False): - type: Required[Literal['unset-sentinel']] +class MissingSentinelSchema(TypedDict, total=False): + type: Required[Literal['missing-sentinel']] -def unset_sentinel_schema() -> UnsetSentinelSchema: - """Returns a schema for the [`UNSET`][pydantic_core.UNSET] sentinel.""" +def missing_sentinel_schema() -> MissingSentinelSchema: + """Returns a schema for the `MISSING` sentinel.""" - return {'type': 'unset-sentinel'} + return {'type': 'missing-sentinel'} # must match input/parse_json.rs::JsonType::try_from @@ -4092,7 +4092,7 @@ def definition_reference_schema( DatetimeSchema, TimedeltaSchema, LiteralSchema, - UnsetSentinelSchema, + MissingSentinelSchema, EnumSchema, IsInstanceSchema, IsSubclassSchema, @@ -4151,7 +4151,7 @@ def definition_reference_schema( 'datetime', 'timedelta', 'literal', - 'unset-sentinel', + 'missing-sentinel', 'enum', 'is-instance', 'is-subclass', @@ -4251,7 +4251,7 @@ def definition_reference_schema( 'value_error', 'assertion_error', 'literal_error', - 'unset_sentinel_error', + 'missing_sentinel_error', 'date_type', 'date_parsing', 'date_from_datetime_parsing', diff --git a/src/common/unset_sentinel.rs b/src/common/missing_sentinel.rs similarity index 60% rename from src/common/unset_sentinel.rs rename to src/common/missing_sentinel.rs index f34d172ed..b23da9bd0 100644 --- a/src/common/unset_sentinel.rs +++ b/src/common/missing_sentinel.rs @@ -2,13 +2,13 @@ use pyo3::intern; use pyo3::prelude::*; use pyo3::sync::GILOnceCell; -static UNSET_SENTINEL_OBJECT: GILOnceCell> = GILOnceCell::new(); +static MISSING_SENTINEL_OBJECT: GILOnceCell> = GILOnceCell::new(); -pub fn get_unset_sentinel_object(py: Python) -> &Bound<'_, PyAny> { - UNSET_SENTINEL_OBJECT +pub fn get_missing_sentinel_object(py: Python) -> &Bound<'_, PyAny> { + MISSING_SENTINEL_OBJECT .get_or_init(py, || { py.import(intern!(py, "pydantic_core")) - .and_then(|core_module| core_module.getattr(intern!(py, "UNSET"))) + .and_then(|core_module| core_module.getattr(intern!(py, "MISSING"))) .unwrap() .into() }) diff --git a/src/common/mod.rs b/src/common/mod.rs index 20ad896b0..776ea7753 100644 --- a/src/common/mod.rs +++ b/src/common/mod.rs @@ -1,3 +1,3 @@ +pub(crate) mod missing_sentinel; pub(crate) mod prebuilt; pub(crate) mod union; -pub(crate) mod unset_sentinel; diff --git a/src/errors/types.rs b/src/errors/types.rs index c85d9253f..d2420629f 100644 --- a/src/errors/types.rs +++ b/src/errors/types.rs @@ -316,8 +316,8 @@ error_types! { expected: {ctx_type: String, ctx_fn: field_from_context}, }, // --------------------- - // unset sentinel - UnsetSentinelError {}, + // missing sentinel + MissingSentinelError {}, // date errors DateType {}, DateParsing { @@ -533,7 +533,7 @@ impl ErrorType { Self::AssertionError {..} => "Assertion failed, {error}", Self::CustomError {..} => "", // custom errors are handled separately Self::LiteralError {..} => "Input should be {expected}", - Self::UnsetSentinelError { .. } => "Input should be the 'UNSET' sentinel", + Self::MissingSentinelError { .. } => "Input should be the 'MISSING' sentinel", Self::DateType {..} => "Input should be a valid date", Self::DateParsing {..} => "Input should be a valid date in the format YYYY-MM-DD, {error}", Self::DateFromDatetimeParsing {..} => "Input should be a valid date or datetime, {error}", diff --git a/src/serializers/computed_fields.rs b/src/serializers/computed_fields.rs index 8867f1920..9870a6682 100644 --- a/src/serializers/computed_fields.rs +++ b/src/serializers/computed_fields.rs @@ -4,7 +4,7 @@ use pyo3::{intern, PyTraverseError, PyVisit}; use serde::ser::SerializeMap; use crate::build_tools::py_schema_error_type; -use crate::common::unset_sentinel::get_unset_sentinel_object; +use crate::common::missing_sentinel::get_missing_sentinel_object; use crate::definitions::DefinitionsBuilder; use crate::py_gc::PyGcTraverse; use crate::serializers::filter::SchemaFilter; @@ -149,8 +149,8 @@ impl ComputedFields { if extra.exclude_none && value.is_none() { continue; } - let unset_obj = get_unset_sentinel_object(model.py()); - if value.is(unset_obj) { + let missing_sentinel = get_missing_sentinel_object(model.py()); + if value.is(missing_sentinel) { continue; } diff --git a/src/serializers/fields.rs b/src/serializers/fields.rs index 2cac7d789..907078e00 100644 --- a/src/serializers/fields.rs +++ b/src/serializers/fields.rs @@ -7,7 +7,7 @@ use ahash::AHashMap; use serde::ser::SerializeMap; use smallvec::SmallVec; -use crate::common::unset_sentinel::get_unset_sentinel_object; +use crate::common::missing_sentinel::get_missing_sentinel_object; use crate::serializers::extra::SerCheck; use crate::PydanticSerializationUnexpectedValue; @@ -169,7 +169,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); + let missing_sentinel = get_missing_sentinel_object(py); // NOTE! we maintain the order of the input dict assuming that's right for result in main_iter { @@ -179,7 +179,7 @@ impl GeneralFieldsSerializer { if extra.exclude_none && value.is_none() { continue; } - if value.is(unset_obj) { + if value.is(missing_sentinel) { continue; } @@ -258,11 +258,11 @@ 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()); + let missing_sentinel = get_missing_sentinel_object(value.py()); if extra.exclude_none && value.is_none() { continue; } - if value.is(unset_obj) { + if value.is(missing_sentinel) { continue; } let key_str = key_str(&key).map_err(py_err_se_err)?; @@ -356,7 +356,7 @@ impl TypeSerializer for GeneralFieldsSerializer { extra: &Extra, ) -> PyResult { let py = value.py(); - let unset_obj = get_unset_sentinel_object(py); + let missing_sentinel = get_missing_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 @@ -378,7 +378,7 @@ impl TypeSerializer for GeneralFieldsSerializer { if extra.exclude_none && value.is_none() { continue; } - if value.is(unset_obj) { + if value.is(missing_sentinel) { continue; } if let Some((next_include, next_exclude)) = self.filter.key_filter(&key, include, exclude)? { @@ -414,7 +414,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()); + let missing_sentinel = get_missing_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 @@ -441,7 +441,7 @@ impl TypeSerializer for GeneralFieldsSerializer { if extra.exclude_none && value.is_none() { continue; } - if value.is(unset_obj) { + if value.is(missing_sentinel) { continue; } let filter = self.filter.key_filter(&key, include, exclude).map_err(py_err_se_err)?; diff --git a/src/serializers/shared.rs b/src/serializers/shared.rs index 9cdcc193b..10f6f52fe 100644 --- a/src/serializers/shared.rs +++ b/src/serializers/shared.rs @@ -142,7 +142,7 @@ combined_serializer! { Union: super::type_serializers::union::UnionSerializer; TaggedUnion: super::type_serializers::union::TaggedUnionSerializer; Literal: super::type_serializers::literal::LiteralSerializer; - UnsetSentinel: super::type_serializers::unset_sentinel::UnsetSentinelSerializer; + MissingSentinel: super::type_serializers::missing_sentinel::MissingSentinelSerializer; Enum: super::type_serializers::enum_::EnumSerializer; Recursive: super::type_serializers::definitions::DefinitionRefSerializer; Tuple: super::type_serializers::tuple::TupleSerializer; @@ -344,7 +344,7 @@ impl PyGcTraverse for CombinedSerializer { CombinedSerializer::Union(inner) => inner.py_gc_traverse(visit), CombinedSerializer::TaggedUnion(inner) => inner.py_gc_traverse(visit), CombinedSerializer::Literal(inner) => inner.py_gc_traverse(visit), - CombinedSerializer::UnsetSentinel(inner) => inner.py_gc_traverse(visit), + CombinedSerializer::MissingSentinel(inner) => inner.py_gc_traverse(visit), CombinedSerializer::Enum(inner) => inner.py_gc_traverse(visit), CombinedSerializer::Recursive(inner) => inner.py_gc_traverse(visit), CombinedSerializer::Tuple(inner) => inner.py_gc_traverse(visit), diff --git a/src/serializers/type_serializers/unset_sentinel.rs b/src/serializers/type_serializers/missing_sentinel.rs similarity index 70% rename from src/serializers/type_serializers/unset_sentinel.rs rename to src/serializers/type_serializers/missing_sentinel.rs index 13c794696..90f20f13e 100644 --- a/src/serializers/type_serializers/unset_sentinel.rs +++ b/src/serializers/type_serializers/missing_sentinel.rs @@ -1,7 +1,7 @@ // This serializer is defined so that building a schema serializer containing an -// 'unset-sentinel' core schema doesn't crash. In practice, the serializer isn't +// 'missing-sentinel' core schema doesn't crash. In practice, the serializer isn't // used for model-like classes, as the 'fields' serializer takes care of omitting -// the fields from the output (the serializer can still be used if the 'unset-sentinel' +// the fields from the output (the serializer can still be used if the 'missing-sentinel' // core schema is used standalone (e.g. with a Pydantic type adapter), but this isn't // something we explicitly support. @@ -12,17 +12,17 @@ use pyo3::types::PyDict; use serde::ser::Error; -use crate::common::unset_sentinel::get_unset_sentinel_object; +use crate::common::missing_sentinel::get_missing_sentinel_object; use crate::definitions::DefinitionsBuilder; use crate::PydanticSerializationUnexpectedValue; use super::{BuildSerializer, CombinedSerializer, Extra, TypeSerializer}; #[derive(Debug)] -pub struct UnsetSentinelSerializer {} +pub struct MissingSentinelSerializer {} -impl BuildSerializer for UnsetSentinelSerializer { - const EXPECTED_TYPE: &'static str = "unset-sentinel"; +impl BuildSerializer for MissingSentinelSerializer { + const EXPECTED_TYPE: &'static str = "missing-sentinel"; fn build( _schema: &Bound<'_, PyDict>, @@ -33,9 +33,9 @@ impl BuildSerializer for UnsetSentinelSerializer { } } -impl_py_gc_traverse!(UnsetSentinelSerializer {}); +impl_py_gc_traverse!(MissingSentinelSerializer {}); -impl TypeSerializer for UnsetSentinelSerializer { +impl TypeSerializer for MissingSentinelSerializer { fn to_python( &self, value: &Bound<'_, PyAny>, @@ -43,13 +43,13 @@ impl TypeSerializer for UnsetSentinelSerializer { _exclude: Option<&Bound<'_, PyAny>>, _extra: &Extra, ) -> PyResult { - let unset_obj = get_unset_sentinel_object(value.py()); + let missing_sentinel = get_missing_sentinel_object(value.py()); - if value.is(unset_obj) { - Ok(unset_obj.to_owned().into()) + if value.is(missing_sentinel) { + Ok(missing_sentinel.to_owned().into()) } else { Err( - PydanticSerializationUnexpectedValue::new_from_msg(Some("Expected 'UNSET' sentinel".to_string())) + PydanticSerializationUnexpectedValue::new_from_msg(Some("Expected 'MISSING' sentinel".to_string())) .to_py_err(), ) } @@ -67,7 +67,7 @@ impl TypeSerializer for UnsetSentinelSerializer { _exclude: Option<&Bound<'_, PyAny>>, _extra: &Extra, ) -> Result { - Err(Error::custom("'UNSET' can't be serialized to JSON".to_string())) + Err(Error::custom("'MISSING' can't be serialized to JSON".to_string())) } fn get_name(&self) -> &str { diff --git a/src/serializers/type_serializers/mod.rs b/src/serializers/type_serializers/mod.rs index 9aefdfe59..5fe990382 100644 --- a/src/serializers/type_serializers/mod.rs +++ b/src/serializers/type_serializers/mod.rs @@ -15,6 +15,7 @@ pub mod json; pub mod json_or_python; pub mod list; pub mod literal; +pub mod missing_sentinel; pub mod model; pub mod nullable; pub mod other; @@ -25,7 +26,6 @@ pub mod timedelta; pub mod tuple; pub mod typed_dict; pub mod union; -pub mod unset_sentinel; pub mod url; pub mod uuid; pub mod with_default; diff --git a/src/validators/unset_sentinel.rs b/src/validators/missing_sentinel.rs similarity index 56% rename from src/validators/unset_sentinel.rs rename to src/validators/missing_sentinel.rs index 2b98283cb..e949772bf 100644 --- a/src/validators/unset_sentinel.rs +++ b/src/validators/missing_sentinel.rs @@ -3,41 +3,41 @@ use core::fmt::Debug; use pyo3::prelude::*; use pyo3::types::PyDict; -use crate::common::unset_sentinel::get_unset_sentinel_object; +use crate::common::missing_sentinel::get_missing_sentinel_object; use crate::errors::{ErrorType, ValError, ValResult}; use crate::input::Input; use super::{BuildValidator, CombinedValidator, DefinitionsBuilder, ValidationState, Validator}; #[derive(Debug, Clone)] -pub struct UnsetSentinelValidator {} +pub struct MissingSentinelValidator {} -impl BuildValidator for UnsetSentinelValidator { - const EXPECTED_TYPE: &'static str = "unset-sentinel"; +impl BuildValidator for MissingSentinelValidator { + const EXPECTED_TYPE: &'static str = "missing-sentinel"; fn build( _schema: &Bound<'_, PyDict>, _config: Option<&Bound<'_, PyDict>>, _definitions: &mut DefinitionsBuilder, ) -> PyResult { - Ok(CombinedValidator::UnsetSentinel(Self {})) + Ok(CombinedValidator::MissingSentinel(Self {})) } } -impl_py_gc_traverse!(UnsetSentinelValidator {}); +impl_py_gc_traverse!(MissingSentinelValidator {}); -impl Validator for UnsetSentinelValidator { +impl Validator for MissingSentinelValidator { fn validate<'py>( &self, py: Python<'py>, input: &(impl Input<'py> + ?Sized), _state: &mut ValidationState<'_, 'py>, ) -> ValResult { - let unset_obj = get_unset_sentinel_object(py); + let missing_sentinel = get_missing_sentinel_object(py); match input.as_python() { - Some(v) if v.is(unset_obj) => Ok(v.to_owned().into()), - _ => Err(ValError::new(ErrorType::UnsetSentinelError { context: None }, input)), + Some(v) if v.is(missing_sentinel) => Ok(v.to_owned().into()), + _ => Err(ValError::new(ErrorType::MissingSentinelError { context: None }, input)), } } diff --git a/src/validators/mod.rs b/src/validators/mod.rs index 8f06af057..2f326cfea 100644 --- a/src/validators/mod.rs +++ b/src/validators/mod.rs @@ -47,6 +47,7 @@ mod json_or_python; mod lax_or_strict; mod list; mod literal; +mod missing_sentinel; mod model; mod model_fields; mod none; @@ -59,7 +60,6 @@ mod timedelta; mod tuple; mod typed_dict; mod union; -mod unset_sentinel; mod url; mod uuid; mod validation_state; @@ -575,8 +575,8 @@ fn build_validator_inner( call::CallValidator, // literals literal::LiteralValidator, - // unset sentinel - unset_sentinel::UnsetSentinelValidator, + // missing sentinel + missing_sentinel::MissingSentinelValidator, // enums enum_::BuildEnumValidator, // any @@ -744,8 +744,8 @@ pub enum CombinedValidator { FunctionCall(call::CallValidator), // literals Literal(literal::LiteralValidator), - // Unset sentinel - UnsetSentinel(unset_sentinel::UnsetSentinelValidator), + // Missing sentinel + MissingSentinel(missing_sentinel::MissingSentinelValidator), // enums IntEnum(enum_::EnumValidator), StrEnum(enum_::EnumValidator), diff --git a/tests/test_errors.py b/tests/test_errors.py index 8f5a198ed..3e52697dd 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -340,7 +340,7 @@ def f(input_value, info): ('assertion_error', 'Assertion failed, foobar', {'error': AssertionError('foobar')}), ('literal_error', 'Input should be foo', {'expected': 'foo'}), ('literal_error', 'Input should be foo or bar', {'expected': 'foo or bar'}), - ('unset_sentinel_error', "Input should be the 'UNSET' sentinel", None), + ('missing_sentinel_error', "Input should be the 'MISSING' sentinel", None), ('date_type', 'Input should be a valid date', None), ('date_parsing', 'Input should be a valid date in the format YYYY-MM-DD, foobar', {'error': 'foobar'}), ('date_from_datetime_parsing', 'Input should be a valid date or datetime, foobar', {'error': 'foobar'}), diff --git a/tests/test_schema_functions.py b/tests/test_schema_functions.py index cf86c4489..92c81b42f 100644 --- a/tests/test_schema_functions.py +++ b/tests/test_schema_functions.py @@ -82,7 +82,7 @@ def args(*args, **kwargs): {'type': 'timedelta', 'microseconds_precision': 'error'}, ), (core_schema.literal_schema, args(['a', 'b']), {'type': 'literal', 'expected': ['a', 'b']}), - (core_schema.unset_sentinel_schema, args(), {'type': 'unset-sentinel'}), + (core_schema.missing_sentinel_schema, args(), {'type': 'missing-sentinel'}), ( core_schema.enum_schema, args(MyEnum, list(MyEnum.__members__.values())),