Skip to content

Commit 6a79deb

Browse files
committed
Add UNSET sentinel
1 parent bd24ed0 commit 6a79deb

File tree

6 files changed

+76
-12
lines changed

6 files changed

+76
-12
lines changed

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@ classifiers = [
3434
'Operating System :: MacOS',
3535
'Typing :: Typed',
3636
]
37-
dependencies = ['typing-extensions>=4.6.0,!=4.7.0']
37+
dependencies = [
38+
'typing-extensions@git+https://github.com/python/typing_extensions',
39+
]
3840
dynamic = ['description', 'license', 'readme', 'version']
3941

4042
[project.urls]

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,
@@ -41,6 +43,7 @@
4143

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

src/serializers/computed_fields.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ use crate::build_tools::py_schema_error_type;
88
use crate::definitions::DefinitionsBuilder;
99
use crate::py_gc::PyGcTraverse;
1010
use crate::serializers::filter::SchemaFilter;
11-
use crate::serializers::shared::{BuildSerializer, CombinedSerializer, PydanticSerializer, TypeSerializer};
11+
use crate::serializers::shared::{
12+
get_unset_sentinel_object, BuildSerializer, CombinedSerializer, PydanticSerializer, TypeSerializer,
13+
};
1214
use crate::tools::SchemaDict;
1315

1416
use super::errors::py_err_se_err;
@@ -87,6 +89,10 @@ impl ComputedFields {
8789
if extra.exclude_none && value.is_none() {
8890
continue;
8991
}
92+
let unset_obj = get_unset_sentinel_object(model.py());
93+
if value.is(unset_obj) {
94+
continue;
95+
}
9096
let field_extra = Extra {
9197
field_name: Some(computed_field.property_name.as_str()),
9298
..*extra
@@ -165,6 +171,10 @@ impl ComputedField {
165171
if extra.exclude_none && value.is_none(py) {
166172
return Ok(());
167173
}
174+
let unset_obj = get_unset_sentinel_object(model.py());
175+
if value.is(unset_obj) {
176+
return Ok(());
177+
}
168178
let key = match extra.serialize_by_alias_or(self.serialize_by_alias) {
169179
true => self.alias_py.bind(py),
170180
false => property_name_py,

src/serializers/fields.rs

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

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

159159
// NOTE! we maintain the order of the input dict assuming that's right
160160
for result in main_iter {
@@ -164,6 +164,10 @@ impl GeneralFieldsSerializer {
164164
if extra.exclude_none && value.is_none() {
165165
continue;
166166
}
167+
if value.is(unset_obj) {
168+
continue;
169+
}
170+
167171
let field_extra = Extra {
168172
field_name: Some(key_str),
169173
..extra
@@ -239,9 +243,13 @@ impl GeneralFieldsSerializer {
239243

240244
for result in main_iter {
241245
let (key, value) = result.map_err(py_err_se_err)?;
246+
let unset_obj = get_unset_sentinel_object(value.py());
242247
if extra.exclude_none && value.is_none() {
243248
continue;
244249
}
250+
if value.is(unset_obj) {
251+
continue;
252+
}
245253
let key_str = key_str(&key).map_err(py_err_se_err)?;
246254
let field_extra = Extra {
247255
field_name: Some(key_str),
@@ -327,6 +335,7 @@ impl TypeSerializer for GeneralFieldsSerializer {
327335
extra: &Extra,
328336
) -> PyResult<PyObject> {
329337
let py = value.py();
338+
let unset_obj = get_unset_sentinel_object(py);
330339
// If there is already a model registered (from a dataclass, BaseModel)
331340
// then do not touch it
332341
// If there is no model, we (a TypedDict) are the model
@@ -362,6 +371,9 @@ impl TypeSerializer for GeneralFieldsSerializer {
362371
if extra.exclude_none && value.is_none() {
363372
continue;
364373
}
374+
if value.is(unset_obj) {
375+
continue;
376+
}
365377
if let Some((next_include, next_exclude)) = self.filter.key_filter(&key, include, exclude)? {
366378
let value = match &self.extra_serializer {
367379
Some(serializer) => {
@@ -395,7 +407,7 @@ impl TypeSerializer for GeneralFieldsSerializer {
395407
extra.warnings.on_fallback_ser::<S>(self.get_name(), value, extra)?;
396408
return infer_serialize(value, serializer, include, exclude, extra);
397409
};
398-
410+
let unset_obj = get_unset_sentinel_object(value.py());
399411
// If there is already a model registered (from a dataclass, BaseModel)
400412
// then do not touch it
401413
// If there is no model, we (a TypedDict) are the model
@@ -436,6 +448,9 @@ impl TypeSerializer for GeneralFieldsSerializer {
436448
if extra.exclude_none && value.is_none() {
437449
continue;
438450
}
451+
if value.is(unset_obj) {
452+
continue;
453+
}
439454
let filter = self.filter.key_filter(&key, include, exclude).map_err(py_err_se_err)?;
440455
if let Some((next_include, next_exclude)) = filter {
441456
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
@@ -33,6 +33,19 @@ pub(crate) trait BuildSerializer: Sized {
3333
) -> PyResult<CombinedSerializer>;
3434
}
3535

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

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)