Skip to content

Commit 8310c5f

Browse files
committed
Add UNSET sentinel
1 parent cabfc43 commit 8310c5f

File tree

6 files changed

+68
-12
lines changed

6 files changed

+68
-12
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ classifiers = [
3636
'Typing :: Typed',
3737
]
3838
dependencies = [
39-
'typing-extensions>=4.13.0',
39+
'typing-extensions@git+https://github.com/python/typing_extensions',
4040
]
4141
dynamic = ['license', 'readme', 'version']
4242

python/pydantic_core/__init__.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import sys as _sys
44
from typing import Any as _Any
55

6+
from typing_extensions import Sentinel
7+
68
from ._pydantic_core import (
79
ArgsKwargs,
810
MultiHostUrl,
@@ -40,6 +42,7 @@
4042

4143
__all__ = [
4244
'__version__',
45+
'UNSET',
4346
'CoreConfig',
4447
'CoreSchema',
4548
'CoreSchemaType',
@@ -140,3 +143,28 @@ class MultiHostHost(_TypedDict):
140143
"""The host part of this host, or `None`."""
141144
port: int | None
142145
"""The port part of this host, or `None`."""
146+
147+
148+
UNSET = Sentinel('UNSET')
149+
"""A singleton indicating a field value was not set during validation.
150+
151+
This singleton can be used a default value, as an alternative to `None` when it has
152+
an explicit meaning. During serialization, any field with `UNSET` as a value is excluded
153+
from the output.
154+
155+
Example:
156+
```python
157+
from pydantic import BaseModel
158+
from pydantic.experimental.unset import UNSET
159+
160+
161+
class Configuration(BaseModel):
162+
timeout: int | None | UNSET = UNSET
163+
164+
165+
# configuration defaults, stored somewhere else:
166+
defaults = {'timeout': 200}
167+
168+
conf = Configuration.model_validate({...})
169+
timeout = conf.timeout if timeout.timeout is not UNSET else defaults['timeout']
170+
"""

src/serializers/computed_fields.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use crate::build_tools::py_schema_error_type;
77
use crate::definitions::DefinitionsBuilder;
88
use crate::py_gc::PyGcTraverse;
99
use crate::serializers::filter::SchemaFilter;
10-
use crate::serializers::shared::{BuildSerializer, CombinedSerializer, PydanticSerializer};
10+
use crate::serializers::shared::{BuildSerializer, CombinedSerializer, PydanticSerializer, get_unset_sentinel_object};
1111
use crate::tools::SchemaDict;
1212

1313
use super::errors::py_err_se_err;
@@ -148,6 +148,10 @@ impl ComputedFields {
148148
if extra.exclude_none && value.is_none() {
149149
continue;
150150
}
151+
let unset_obj = get_unset_sentinel_object(model.py());
152+
if value.is(unset_obj) {
153+
continue;
154+
}
151155

152156
let field_extra = Extra {
153157
field_name: Some(&computed_field.property_name),

src/serializers/fields.rs

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,7 @@ use super::errors::py_err_se_err;
1515
use super::extra::Extra;
1616
use super::filter::SchemaFilter;
1717
use super::infer::{infer_json_key, infer_serialize, infer_to_python, SerializeInfer};
18-
use super::shared::PydanticSerializer;
19-
use super::shared::{CombinedSerializer, TypeSerializer};
18+
use super::shared::{get_unset_sentinel_object, CombinedSerializer, PydanticSerializer, TypeSerializer};
2019

2120
/// representation of a field for serialization
2221
#[derive(Debug)]
@@ -154,6 +153,7 @@ impl GeneralFieldsSerializer {
154153
) -> PyResult<Bound<'py, PyDict>> {
155154
let output_dict = PyDict::new(py);
156155
let mut used_req_fields: usize = 0;
156+
let unset_obj = get_unset_sentinel_object(py);
157157

158158
// NOTE! we maintain the order of the input dict assuming that's right
159159
for result in main_iter {
@@ -163,6 +163,10 @@ impl GeneralFieldsSerializer {
163163
if extra.exclude_none && value.is_none() {
164164
continue;
165165
}
166+
if value.is(unset_obj) {
167+
continue;
168+
}
169+
166170
let field_extra = Extra {
167171
field_name: Some(key_str),
168172
..extra
@@ -238,9 +242,13 @@ impl GeneralFieldsSerializer {
238242

239243
for result in main_iter {
240244
let (key, value) = result.map_err(py_err_se_err)?;
245+
let unset_obj = get_unset_sentinel_object(value.py());
241246
if extra.exclude_none && value.is_none() {
242247
continue;
243248
}
249+
if value.is(unset_obj) {
250+
continue;
251+
}
244252
let key_str = key_str(&key).map_err(py_err_se_err)?;
245253
let field_extra = Extra {
246254
field_name: Some(key_str),
@@ -326,6 +334,7 @@ impl TypeSerializer for GeneralFieldsSerializer {
326334
extra: &Extra,
327335
) -> PyResult<PyObject> {
328336
let py = value.py();
337+
let unset_obj = get_unset_sentinel_object(py);
329338
// If there is already a model registered (from a dataclass, BaseModel)
330339
// then do not touch it
331340
// If there is no model, we (a TypedDict) are the model
@@ -347,6 +356,9 @@ impl TypeSerializer for GeneralFieldsSerializer {
347356
if extra.exclude_none && value.is_none() {
348357
continue;
349358
}
359+
if value.is(unset_obj) {
360+
continue;
361+
}
350362
if let Some((next_include, next_exclude)) = self.filter.key_filter(&key, include, exclude)? {
351363
let value = match &self.extra_serializer {
352364
Some(serializer) => {
@@ -380,7 +392,7 @@ impl TypeSerializer for GeneralFieldsSerializer {
380392
extra.warnings.on_fallback_ser::<S>(self.get_name(), value, extra)?;
381393
return infer_serialize(value, serializer, include, exclude, extra);
382394
};
383-
395+
let unset_obj = get_unset_sentinel_object(value.py());
384396
// If there is already a model registered (from a dataclass, BaseModel)
385397
// then do not touch it
386398
// If there is no model, we (a TypedDict) are the model
@@ -407,6 +419,9 @@ impl TypeSerializer for GeneralFieldsSerializer {
407419
if extra.exclude_none && value.is_none() {
408420
continue;
409421
}
422+
if value.is(unset_obj) {
423+
continue;
424+
}
410425
let filter = self.filter.key_filter(&key, include, exclude).map_err(py_err_se_err)?;
411426
if let Some((next_include, next_exclude)) = filter {
412427
let output_key = infer_json_key(&key, extra).map_err(py_err_se_err)?;

src/serializers/shared.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,19 @@ pub(crate) trait BuildSerializer: Sized {
3434
) -> PyResult<CombinedSerializer>;
3535
}
3636

37+
static UNSET_SENTINEL_OBJECT: GILOnceCell<Py<PyAny>> = GILOnceCell::new();
38+
39+
pub fn get_unset_sentinel_object(py: Python) -> &Bound<'_, PyAny> {
40+
UNSET_SENTINEL_OBJECT
41+
.get_or_init(py, || {
42+
py.import(intern!(py, "pydantic_core"))
43+
.and_then(|core_module| core_module.getattr(intern!(py, "UNSET")))
44+
.unwrap()
45+
.into()
46+
})
47+
.bind(py)
48+
}
49+
3750
/// Build the `CombinedSerializer` enum and implement a `find_serializer` method for it.
3851
macro_rules! combined_serializer {
3952
(

uv.lock

Lines changed: 3 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)