Skip to content

Commit b787c41

Browse files
authored
Add MISSING sentinel (#1711)
As we now require `typing-extensions>=4.14.1`, we also update pyodide to 0.27.7 to fix an issue with dependencies specification.
1 parent b1c9f21 commit b787c41

File tree

23 files changed

+263
-76
lines changed

23 files changed

+263
-76
lines changed

.github/workflows/ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -329,8 +329,8 @@ jobs:
329329
- uses: mymindstorm/setup-emsdk@v14
330330
with:
331331
# NOTE!: as per https://github.com/pydantic/pydantic-core/pull/149 this version needs to match the version
332-
# in node_modules/pyodide/repodata.json, to get the version, run:
333-
# `cat node_modules/pyodide/repodata.json | python -m json.tool | rg platform`
332+
# in node_modules/pyodide/pyodide-lock.json, to get the version, run:
333+
# `cat node_modules/pyodide/pyodide-lock.json | jq .info.platform`
334334
version: '3.1.58'
335335
actions-cache-folder: emsdk-cache
336336

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
"main": "tests/emscripten_runner.js",
99
"dependencies": {
1010
"prettier": "^2.7.1",
11-
"pyodide": "^0.26.3"
11+
"pyodide": "^0.27.7"
1212
},
1313
"scripts": {
1414
"test": "node tests/emscripten_runner.js",

pyproject.toml

Lines changed: 2 additions & 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>=4.14.1',
4040
]
4141
dynamic = ['license', 'readme', 'version']
4242

@@ -160,3 +160,4 @@ fix = ["create", "fix"]
160160
# this ensures that `uv run` doesn't actually build the package; a `make`
161161
# command is needed to build
162162
package = false
163+
required-version = '>=0.7.2'

python/pydantic_core/__init__.py

Lines changed: 29 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,29 @@ 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+
MISSING = Sentinel('MISSING')
149+
"""A singleton indicating a field value was not provided 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 `MISSING` as a value is excluded
153+
from the output.
154+
155+
Example:
156+
```python
157+
from pydantic import BaseModel
158+
159+
from pydantic_core import MISSING
160+
161+
162+
class Configuration(BaseModel):
163+
timeout: int | None | MISSING = MISSING
164+
165+
166+
# configuration defaults, stored somewhere else:
167+
defaults = {'timeout': 200}
168+
169+
conf = Configuration.model_validate({...})
170+
timeout = conf.timeout if timeout.timeout is not MISSING else defaults['timeout']
171+
"""

python/pydantic_core/core_schema.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1346,6 +1346,16 @@ class Color(Enum):
13461346
)
13471347

13481348

1349+
class MissingSentinelSchema(TypedDict, total=False):
1350+
type: Required[Literal['missing-sentinel']]
1351+
1352+
1353+
def missing_sentinel_schema() -> MissingSentinelSchema:
1354+
"""Returns a schema for the `MISSING` sentinel."""
1355+
1356+
return {'type': 'missing-sentinel'}
1357+
1358+
13491359
# must match input/parse_json.rs::JsonType::try_from
13501360
JsonType = Literal['null', 'bool', 'int', 'float', 'str', 'list', 'dict']
13511361

@@ -4082,6 +4092,7 @@ def definition_reference_schema(
40824092
DatetimeSchema,
40834093
TimedeltaSchema,
40844094
LiteralSchema,
4095+
MissingSentinelSchema,
40854096
EnumSchema,
40864097
IsInstanceSchema,
40874098
IsSubclassSchema,
@@ -4140,6 +4151,7 @@ def definition_reference_schema(
41404151
'datetime',
41414152
'timedelta',
41424153
'literal',
4154+
'missing-sentinel',
41434155
'enum',
41444156
'is-instance',
41454157
'is-subclass',
@@ -4239,6 +4251,7 @@ def definition_reference_schema(
42394251
'value_error',
42404252
'assertion_error',
42414253
'literal_error',
4254+
'missing_sentinel_error',
42424255
'date_type',
42434256
'date_parsing',
42444257
'date_from_datetime_parsing',

src/common/missing_sentinel.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
use pyo3::intern;
2+
use pyo3::prelude::*;
3+
use pyo3::sync::GILOnceCell;
4+
5+
static MISSING_SENTINEL_OBJECT: GILOnceCell<Py<PyAny>> = GILOnceCell::new();
6+
7+
pub fn get_missing_sentinel_object(py: Python) -> &Bound<'_, PyAny> {
8+
MISSING_SENTINEL_OBJECT
9+
.get_or_init(py, || {
10+
py.import(intern!(py, "pydantic_core"))
11+
.and_then(|core_module| core_module.getattr(intern!(py, "MISSING")))
12+
.unwrap()
13+
.into()
14+
})
15+
.bind(py)
16+
}

src/common/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1+
pub(crate) mod missing_sentinel;
12
pub(crate) mod prebuilt;
23
pub(crate) mod union;

src/errors/types.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,8 @@ error_types! {
316316
expected: {ctx_type: String, ctx_fn: field_from_context},
317317
},
318318
// ---------------------
319+
// missing sentinel
320+
MissingSentinelError {},
319321
// date errors
320322
DateType {},
321323
DateParsing {
@@ -531,6 +533,7 @@ impl ErrorType {
531533
Self::AssertionError {..} => "Assertion failed, {error}",
532534
Self::CustomError {..} => "", // custom errors are handled separately
533535
Self::LiteralError {..} => "Input should be {expected}",
536+
Self::MissingSentinelError { .. } => "Input should be the 'MISSING' sentinel",
534537
Self::DateType {..} => "Input should be a valid date",
535538
Self::DateParsing {..} => "Input should be a valid date in the format YYYY-MM-DD, {error}",
536539
Self::DateFromDatetimeParsing {..} => "Input should be a valid date or datetime, {error}",

src/serializers/computed_fields.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use pyo3::{intern, PyTraverseError, PyVisit};
44
use serde::ser::SerializeMap;
55

66
use crate::build_tools::py_schema_error_type;
7+
use crate::common::missing_sentinel::get_missing_sentinel_object;
78
use crate::definitions::DefinitionsBuilder;
89
use crate::py_gc::PyGcTraverse;
910
use crate::serializers::filter::SchemaFilter;
@@ -148,6 +149,10 @@ impl ComputedFields {
148149
if extra.exclude_none && value.is_none() {
149150
continue;
150151
}
152+
let missing_sentinel = get_missing_sentinel_object(model.py());
153+
if value.is(missing_sentinel) {
154+
continue;
155+
}
151156

152157
let field_extra = Extra {
153158
field_name: Some(&computed_field.property_name),

src/serializers/fields.rs

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use ahash::AHashMap;
77
use serde::ser::SerializeMap;
88
use smallvec::SmallVec;
99

10+
use crate::common::missing_sentinel::get_missing_sentinel_object;
1011
use crate::serializers::extra::SerCheck;
1112
use crate::PydanticSerializationUnexpectedValue;
1213

@@ -15,8 +16,7 @@ use super::errors::py_err_se_err;
1516
use super::extra::Extra;
1617
use super::filter::SchemaFilter;
1718
use super::infer::{infer_json_key, infer_serialize, infer_to_python, SerializeInfer};
18-
use super::shared::PydanticSerializer;
19-
use super::shared::{CombinedSerializer, TypeSerializer};
19+
use super::shared::{CombinedSerializer, PydanticSerializer, TypeSerializer};
2020

2121
/// representation of a field for serialization
2222
#[derive(Debug)]
@@ -169,6 +169,7 @@ impl GeneralFieldsSerializer {
169169
) -> PyResult<Bound<'py, PyDict>> {
170170
let output_dict = PyDict::new(py);
171171
let mut used_req_fields: usize = 0;
172+
let missing_sentinel = get_missing_sentinel_object(py);
172173

173174
// NOTE! we maintain the order of the input dict assuming that's right
174175
for result in main_iter {
@@ -178,6 +179,10 @@ impl GeneralFieldsSerializer {
178179
if extra.exclude_none && value.is_none() {
179180
continue;
180181
}
182+
if value.is(missing_sentinel) {
183+
continue;
184+
}
185+
181186
let field_extra = Extra {
182187
field_name: Some(key_str),
183188
..extra
@@ -253,9 +258,13 @@ impl GeneralFieldsSerializer {
253258

254259
for result in main_iter {
255260
let (key, value) = result.map_err(py_err_se_err)?;
261+
let missing_sentinel = get_missing_sentinel_object(value.py());
256262
if extra.exclude_none && value.is_none() {
257263
continue;
258264
}
265+
if value.is(missing_sentinel) {
266+
continue;
267+
}
259268
let key_str = key_str(&key).map_err(py_err_se_err)?;
260269
let field_extra = Extra {
261270
field_name: Some(key_str),
@@ -347,6 +356,7 @@ impl TypeSerializer for GeneralFieldsSerializer {
347356
extra: &Extra,
348357
) -> PyResult<PyObject> {
349358
let py = value.py();
359+
let missing_sentinel = get_missing_sentinel_object(py);
350360
// If there is already a model registered (from a dataclass, BaseModel)
351361
// then do not touch it
352362
// If there is no model, we (a TypedDict) are the model
@@ -368,6 +378,9 @@ impl TypeSerializer for GeneralFieldsSerializer {
368378
if extra.exclude_none && value.is_none() {
369379
continue;
370380
}
381+
if value.is(missing_sentinel) {
382+
continue;
383+
}
371384
if let Some((next_include, next_exclude)) = self.filter.key_filter(&key, include, exclude)? {
372385
let value = match &self.extra_serializer {
373386
Some(serializer) => {
@@ -401,7 +414,7 @@ impl TypeSerializer for GeneralFieldsSerializer {
401414
extra.warnings.on_fallback_ser::<S>(self.get_name(), value, extra)?;
402415
return infer_serialize(value, serializer, include, exclude, extra);
403416
};
404-
417+
let missing_sentinel = get_missing_sentinel_object(value.py());
405418
// If there is already a model registered (from a dataclass, BaseModel)
406419
// then do not touch it
407420
// If there is no model, we (a TypedDict) are the model
@@ -428,6 +441,9 @@ impl TypeSerializer for GeneralFieldsSerializer {
428441
if extra.exclude_none && value.is_none() {
429442
continue;
430443
}
444+
if value.is(missing_sentinel) {
445+
continue;
446+
}
431447
let filter = self.filter.key_filter(&key, include, exclude).map_err(py_err_se_err)?;
432448
if let Some((next_include, next_exclude)) = filter {
433449
let output_key = infer_json_key(&key, extra).map_err(py_err_se_err)?;

0 commit comments

Comments
 (0)