Skip to content

Commit 2acf1af

Browse files
Kludexdmontagu
andauthored
Use ser_json_<timedelta|bytes> on default in GenerateJsonSchema (pydantic#7269)
Co-authored-by: David Montague <[email protected]>
1 parent 84282ef commit 2acf1af

File tree

5 files changed

+232
-48
lines changed

5 files changed

+232
-48
lines changed

pydantic/_internal/_config.py

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,21 @@
11
from __future__ import annotations as _annotations
22

33
import warnings
4-
from typing import TYPE_CHECKING, Any, Callable, cast
4+
from contextlib import contextmanager, nullcontext
5+
from typing import (
6+
TYPE_CHECKING,
7+
Any,
8+
Callable,
9+
ContextManager,
10+
Iterator,
11+
cast,
12+
)
513

614
from pydantic_core import core_schema
7-
from typing_extensions import Literal, Self
15+
from typing_extensions import (
16+
Literal,
17+
Self,
18+
)
819

920
from ..config import ConfigDict, ExtraValues, JsonEncoder, JsonSchemaExtraCallable
1021
from ..errors import PydanticUserError
@@ -169,6 +180,34 @@ def __repr__(self):
169180
return f'ConfigWrapper({c})'
170181

171182

183+
class ConfigWrapperStack:
184+
"""A stack of `ConfigWrapper` instances."""
185+
186+
def __init__(self, config_wrapper: ConfigWrapper):
187+
self._config_wrapper_stack: list[ConfigWrapper] = [config_wrapper]
188+
189+
@property
190+
def tail(self) -> ConfigWrapper:
191+
return self._config_wrapper_stack[-1]
192+
193+
def push(self, config_wrapper: ConfigWrapper | ConfigDict | None) -> ContextManager[None]:
194+
if config_wrapper is None:
195+
return nullcontext()
196+
197+
if not isinstance(config_wrapper, ConfigWrapper):
198+
config_wrapper = ConfigWrapper(config_wrapper, check=False)
199+
200+
@contextmanager
201+
def _context_manager() -> Iterator[None]:
202+
self._config_wrapper_stack.append(config_wrapper)
203+
try:
204+
yield
205+
finally:
206+
self._config_wrapper_stack.pop()
207+
208+
return _context_manager()
209+
210+
172211
config_defaults = ConfigDict(
173212
title=None,
174213
str_to_lower=False,

pydantic/_internal/_core_metadata.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ class CoreMetadata(typing_extensions.TypedDict, total=False):
3030
# prefer positional over keyword arguments for an 'arguments' schema.
3131
pydantic_js_prefer_positional_arguments: bool | None
3232

33+
pydantic_typed_dict_cls: type[Any] | None # TODO: Consider moving this into the pydantic-core TypedDictSchema
34+
3335

3436
class CoreMetadataHandler:
3537
"""Because the metadata field in pydantic_core is of type `Any`, we can't assume much about its contents.
@@ -67,6 +69,7 @@ def build_metadata_dict(
6769
js_functions: list[GetJsonSchemaFunction] | None = None,
6870
js_annotation_functions: list[GetJsonSchemaFunction] | None = None,
6971
js_prefer_positional_arguments: bool | None = None,
72+
typed_dict_cls: type[Any] | None = None,
7073
initial_metadata: Any | None = None,
7174
) -> Any:
7275
"""Builds a dict to use as the metadata field of a CoreSchema object in a manner that is consistent
@@ -79,6 +82,7 @@ def build_metadata_dict(
7982
pydantic_js_functions=js_functions or [],
8083
pydantic_js_annotation_functions=js_annotation_functions or [],
8184
pydantic_js_prefer_positional_arguments=js_prefer_positional_arguments,
85+
pydantic_typed_dict_cls=typed_dict_cls,
8286
)
8387
metadata = {k: v for k, v in metadata.items() if v is not None}
8488

pydantic/_internal/_generate_schema.py

Lines changed: 5 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import sys
99
import typing
1010
import warnings
11-
from contextlib import contextmanager, nullcontext
11+
from contextlib import contextmanager
1212
from copy import copy
1313
from enum import Enum
1414
from functools import partial
@@ -20,7 +20,6 @@
2020
TYPE_CHECKING,
2121
Any,
2222
Callable,
23-
ContextManager,
2423
Dict,
2524
ForwardRef,
2625
Iterable,
@@ -45,7 +44,7 @@
4544
from ..warnings import PydanticDeprecatedSince20
4645
from . import _decorators, _discriminated_union, _known_annotated_metadata, _typing_extra
4746
from ._annotated_handlers import GetCoreSchemaHandler, GetJsonSchemaHandler
48-
from ._config import ConfigWrapper
47+
from ._config import ConfigWrapper, ConfigWrapperStack
4948
from ._core_metadata import (
5049
CoreMetadataHandler,
5150
build_metadata_dict,
@@ -261,34 +260,6 @@ def _add_custom_serialization_from_json_encoders(
261260
return schema
262261

263262

264-
class ConfigWrapperStack:
265-
"""A stack of `ConfigWrapper` instances."""
266-
267-
def __init__(self, config_wrapper: ConfigWrapper):
268-
self._config_wrapper_stack: list[ConfigWrapper] = [config_wrapper]
269-
270-
@property
271-
def tail(self) -> ConfigWrapper:
272-
return self._config_wrapper_stack[-1]
273-
274-
def push(self, config_wrapper: ConfigWrapper | ConfigDict | None) -> ContextManager[None]:
275-
if config_wrapper is None:
276-
return nullcontext()
277-
278-
if not isinstance(config_wrapper, ConfigWrapper):
279-
config_wrapper = ConfigWrapper(config_wrapper, check=False)
280-
281-
@contextmanager
282-
def _context_manager() -> Iterator[None]:
283-
self._config_wrapper_stack.append(config_wrapper)
284-
try:
285-
yield
286-
finally:
287-
self._config_wrapper_stack.pop()
288-
289-
return _context_manager()
290-
291-
292263
class GenerateSchema:
293264
"""Generate core schema for a Pydantic model, dataclass and types like `str`, `datetime`, ... ."""
294265

@@ -1098,7 +1069,9 @@ def _typed_dict_schema(self, typed_dict_cls: Any, origin: Any) -> core_schema.Co
10981069
field_name, field_info, decorators, required=required
10991070
)
11001071

1101-
metadata = build_metadata_dict(js_functions=[partial(modify_model_json_schema, cls=typed_dict_cls)])
1072+
metadata = build_metadata_dict(
1073+
js_functions=[partial(modify_model_json_schema, cls=typed_dict_cls)], typed_dict_cls=typed_dict_cls
1074+
)
11021075

11031076
td_schema = core_schema.typed_dict_schema(
11041077
fields,

pydantic/json_schema.py

Lines changed: 48 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,21 @@
3535
)
3636

3737
import pydantic_core
38-
from pydantic_core import CoreConfig, CoreSchema, PydanticOmit, core_schema, to_jsonable_python
38+
from pydantic_core import CoreSchema, PydanticOmit, core_schema, to_jsonable_python
3939
from pydantic_core.core_schema import ComputedField
4040
from typing_extensions import Annotated, Literal, assert_never
4141

42-
from pydantic._internal import _annotated_handlers, _internal_dataclass
43-
44-
from ._internal import _core_metadata, _core_utils, _mock_val_ser, _schema_generation_shared, _typing_extra
42+
from ._internal import (
43+
_annotated_handlers,
44+
_config,
45+
_core_metadata,
46+
_core_utils,
47+
_decorators,
48+
_internal_dataclass,
49+
_mock_val_ser,
50+
_schema_generation_shared,
51+
_typing_extra,
52+
)
4553
from .config import JsonSchemaExtraCallable
4654
from .errors import PydanticInvalidForJsonSchema, PydanticUserError
4755

@@ -266,6 +274,7 @@ def __init__(self, by_alias: bool = True, ref_template: str = DEFAULT_REF_TEMPLA
266274
self.json_to_defs_refs: dict[JsonRef, DefsRef] = {}
267275

268276
self.definitions: dict[DefsRef, JsonSchemaValue] = {}
277+
self._config_wrapper_stack = _config.ConfigWrapperStack(_config.ConfigWrapper({}))
269278

270279
self.mode: JsonSchemaMode = 'validation'
271280

@@ -291,6 +300,10 @@ def __init__(self, by_alias: bool = True, ref_template: str = DEFAULT_REF_TEMPLA
291300
# of a single instance of a schema generator
292301
self._used = False
293302

303+
@property
304+
def _config(self) -> _config.ConfigWrapper:
305+
return self._config_wrapper_stack.tail
306+
294307
def build_schema_type_to_method(
295308
self,
296309
) -> dict[CoreSchemaOrFieldType, Callable[[CoreSchemaOrField], JsonSchemaValue]]:
@@ -649,7 +662,7 @@ def bytes_schema(self, schema: core_schema.BytesSchema) -> JsonSchemaValue:
649662
Returns:
650663
The generated JSON schema.
651664
"""
652-
json_schema = {'type': 'string', 'format': 'binary'}
665+
json_schema = {'type': 'string', 'format': 'base64url' if self._config.ser_json_bytes == 'base64' else 'binary'}
653666
self.update_with_validations(json_schema, schema, self.ValidationsMapping.bytes)
654667
return json_schema
655668

@@ -697,6 +710,8 @@ def timedelta_schema(self, schema: core_schema.TimedeltaSchema) -> JsonSchemaVal
697710
Returns:
698711
The generated JSON schema.
699712
"""
713+
if self._config.ser_json_timedelta == 'float':
714+
return {'type': 'number'}
700715
return {'type': 'string', 'format': 'duration'}
701716

702717
def literal_schema(self, schema: core_schema.LiteralSchema) -> JsonSchemaValue:
@@ -1168,10 +1183,12 @@ def typed_dict_schema(self, schema: core_schema.TypedDictSchema) -> JsonSchemaVa
11681183
]
11691184
if self.mode == 'serialization':
11701185
named_required_fields.extend(self._name_required_computed_fields(schema.get('computed_fields', [])))
1171-
json_schema = self._named_required_fields_schema(named_required_fields)
1172-
config: CoreConfig | None = schema.get('config', None)
11731186

1174-
extra = (config or {}).get('extra_fields_behavior', 'ignore')
1187+
config = _get_typed_dict_config(schema)
1188+
with self._config_wrapper_stack.push(config):
1189+
json_schema = self._named_required_fields_schema(named_required_fields)
1190+
1191+
extra = config.get('extra', 'ignore')
11751192
if extra == 'forbid':
11761193
json_schema['additionalProperties'] = False
11771194
elif extra == 'allow':
@@ -1286,12 +1303,13 @@ def model_schema(self, schema: core_schema.ModelSchema) -> JsonSchemaValue:
12861303
"""
12871304
# We do not use schema['model'].model_json_schema() here
12881305
# because it could lead to inconsistent refs handling, etc.
1289-
json_schema = self.generate_inner(schema['schema'])
1290-
12911306
cls = cast('type[BaseModel]', schema['cls'])
12921307
config = cls.model_config
12931308
title = config.get('title')
12941309

1310+
with self._config_wrapper_stack.push(config):
1311+
json_schema = self.generate_inner(schema['schema'])
1312+
12951313
json_schema_extra = config.get('json_schema_extra')
12961314
if cls.__pydantic_root_model__:
12971315
root_json_schema_extra = cls.model_fields['root'].json_schema_extra
@@ -1461,13 +1479,13 @@ def dataclass_schema(self, schema: core_schema.DataclassSchema) -> JsonSchemaVal
14611479
Returns:
14621480
The generated JSON schema.
14631481
"""
1464-
json_schema = self.generate_inner(schema['schema']).copy()
1465-
14661482
cls = schema['cls']
14671483
config: ConfigDict = getattr(cls, '__pydantic_config__', cast('ConfigDict', {}))
1468-
14691484
title = config.get('title') or cls.__name__
14701485

1486+
with self._config_wrapper_stack.push(config):
1487+
json_schema = self.generate_inner(schema['schema']).copy()
1488+
14711489
json_schema_extra = config.get('json_schema_extra')
14721490
json_schema = self._update_class_schema(json_schema, title, config.get('extra', None), cls, json_schema_extra)
14731491

@@ -1942,7 +1960,12 @@ def encode_default(self, dft: Any) -> Any:
19421960
Returns:
19431961
The encoded default value.
19441962
"""
1945-
return pydantic_core.to_jsonable_python(dft)
1963+
config = self._config
1964+
return pydantic_core.to_jsonable_python(
1965+
dft,
1966+
timedelta_mode=config.ser_json_timedelta,
1967+
bytes_mode=config.ser_json_bytes,
1968+
)
19461969

19471970
def update_with_validations(
19481971
self, json_schema: JsonSchemaValue, core_schema: CoreSchema, mapping: dict[str, str]
@@ -2321,3 +2344,14 @@ def __get_pydantic_json_schema__(
23212344

23222345
def __hash__(self) -> int:
23232346
return hash(type(self))
2347+
2348+
2349+
def _get_typed_dict_config(schema: core_schema.TypedDictSchema) -> ConfigDict:
2350+
metadata = _core_metadata.CoreMetadataHandler(schema).metadata
2351+
cls = metadata.get('pydantic_typed_dict_cls')
2352+
if cls is not None:
2353+
try:
2354+
return _decorators.get_attribute_from_bases(cls, '__pydantic_config__')
2355+
except AttributeError:
2356+
pass
2357+
return {}

0 commit comments

Comments
 (0)