1from __future__ import annotations as _annotations
2
3import warnings
4from collections.abc import Callable
5from contextlib import contextmanager
6from re import Pattern
7from typing import (
8 TYPE_CHECKING,
9 Any,
10 Literal,
11 cast,
12)
13
14from pydantic_core import core_schema
15from typing_extensions import Self
16
17from ..aliases import AliasGenerator
18from ..config import ConfigDict, ExtraValues, JsonDict, JsonEncoder, JsonSchemaExtraCallable
19from ..errors import PydanticUserError
20from ..warnings import PydanticDeprecatedSince20, PydanticDeprecatedSince210
21
22if TYPE_CHECKING:
23 from .._internal._schema_generation_shared import GenerateSchema
24 from ..fields import ComputedFieldInfo, FieldInfo
25
26DEPRECATION_MESSAGE = 'Support for class-based `config` is deprecated, use ConfigDict instead.'
27
28# Maps the `ConfigDict` keys that have a `CoreConfig` counterpart to the corresponding
29# `CoreConfig` key (`'title'` is omitted as it requires special handling, see `ConfigWrapper.core_config()`):
30_config_dict_to_core_config_key: dict[str, str] = {
31 'extra': 'extra_fields_behavior',
32 'allow_inf_nan': 'allow_inf_nan',
33 'str_strip_whitespace': 'str_strip_whitespace',
34 'str_to_lower': 'str_to_lower',
35 'str_to_upper': 'str_to_upper',
36 'strict': 'strict',
37 'ser_json_timedelta': 'ser_json_timedelta',
38 'ser_json_temporal': 'ser_json_temporal',
39 'val_temporal_unit': 'val_temporal_unit',
40 'ser_json_bytes': 'ser_json_bytes',
41 'val_json_bytes': 'val_json_bytes',
42 'ser_json_inf_nan': 'ser_json_inf_nan',
43 'from_attributes': 'from_attributes',
44 'loc_by_alias': 'loc_by_alias',
45 'revalidate_instances': 'revalidate_instances',
46 'validate_default': 'validate_default',
47 'str_max_length': 'str_max_length',
48 'str_min_length': 'str_min_length',
49 'hide_input_in_errors': 'hide_input_in_errors',
50 'coerce_numbers_to_str': 'coerce_numbers_to_str',
51 'regex_engine': 'regex_engine',
52 'validation_error_cause': 'validation_error_cause',
53 'cache_strings': 'cache_strings',
54 'validate_by_alias': 'validate_by_alias',
55 'validate_by_name': 'validate_by_name',
56 'serialize_by_alias': 'serialize_by_alias',
57 'url_preserve_empty_path': 'url_preserve_empty_path',
58 'polymorphic_serialization': 'polymorphic_serialization',
59}
60
61
62class ConfigWrapper:
63 """Internal wrapper for Config which exposes ConfigDict items as attributes."""
64
65 __slots__ = ('config_dict', 'effective_config')
66
67 config_dict: ConfigDict
68 """The configuration as declared by the user.
69
70 This is the mapping exposed as the `model_config` attribute of models (and the
71 `__pydantic_config__` attribute of dataclasses), and the one merged with the
72 configuration of the bases when subclassing. As such, it must never be mutated.
73 """
74
75 effective_config: ConfigDict
76 """The configuration actually in effect, with the deprecated settings applied.
77
78 This is what every consumer of the configuration should read (both the attribute
79 access on this wrapper and `core_config()` do), as `config_dict` alone does not
80 account for the deprecated settings (see `_build_effective_config()`).
81 """
82
83 # all annotations are copied directly from ConfigDict, and should be kept up to date, a test will fail if they
84 # stop matching
85 title: str | None
86 str_to_lower: bool
87 str_to_upper: bool
88 str_strip_whitespace: bool
89 str_min_length: int
90 str_max_length: int | None
91 extra: ExtraValues | None
92 frozen: bool
93 populate_by_name: bool
94 use_enum_values: bool
95 validate_assignment: bool
96 arbitrary_types_allowed: bool
97 from_attributes: bool
98 # whether to use the actual key provided in the data (e.g. alias or first alias for "field required" errors) instead of field_names
99 # to construct error `loc`s, default `True`
100 loc_by_alias: bool
101 alias_generator: Callable[[str], str] | AliasGenerator | None
102 model_title_generator: Callable[[type], str] | None
103 field_title_generator: Callable[[str, FieldInfo | ComputedFieldInfo], str] | None
104 ignored_types: tuple[type, ...]
105 allow_inf_nan: bool
106 json_schema_extra: JsonDict | JsonSchemaExtraCallable | None
107 json_encoders: dict[type[object], JsonEncoder] | None
108
109 # new in V2
110 strict: bool
111 # whether instances of models and dataclasses (including subclass instances) should re-validate, default 'never'
112 revalidate_instances: Literal['always', 'never', 'subclass-instances']
113 ser_json_timedelta: Literal['iso8601', 'float']
114 ser_json_temporal: Literal['iso8601', 'seconds', 'milliseconds']
115 val_temporal_unit: Literal['seconds', 'milliseconds', 'infer']
116 ser_json_bytes: Literal['utf8', 'base64', 'hex']
117 val_json_bytes: Literal['utf8', 'base64', 'hex']
118 ser_json_inf_nan: Literal['null', 'constants', 'strings']
119 # whether to validate default values during validation, default False
120 validate_default: bool
121 validate_return: bool
122 protected_namespaces: tuple[str | Pattern[str], ...]
123 hide_input_in_errors: bool
124 defer_build: bool
125 plugin_settings: dict[str, object] | None
126 schema_generator: type[GenerateSchema] | None
127 json_schema_serialization_defaults_required: bool
128 json_schema_mode_override: Literal['validation', 'serialization', None]
129 coerce_numbers_to_str: bool
130 regex_engine: Literal['rust-regex', 'python-re']
131 validation_error_cause: bool
132 use_attribute_docstrings: bool
133 cache_strings: bool | Literal['all', 'keys', 'none']
134 validate_by_alias: bool
135 validate_by_name: bool
136 serialize_by_alias: bool
137 url_preserve_empty_path: bool
138 polymorphic_serialization: bool
139
140 def __init__(self, config: ConfigDict | dict[str, Any] | type[Any] | None, *, check: bool = True):
141 if check:
142 self.config_dict = prepare_config(config)
143 else:
144 self.config_dict = cast(ConfigDict, config)
145 self.effective_config = _build_effective_config(self.config_dict)
146
147 @classmethod
148 def for_model(
149 cls,
150 bases: tuple[type[Any], ...],
151 namespace: dict[str, Any],
152 kwargs: dict[str, Any],
153 ) -> Self:
154 """Build a new `ConfigWrapper` instance for a `BaseModel`.
155
156 The config wrapper built based on (in descending order of priority):
157 - options from `kwargs`
158 - options from the `namespace`
159 - options from the base classes (`bases`)
160
161 Args:
162 bases: A tuple of base classes.
163 namespace: The namespace of the class being created.
164 kwargs: The kwargs passed to the class being created.
165
166 Returns:
167 A `ConfigWrapper` instance for `BaseModel`.
168 """
169 config_new = ConfigDict()
170 for base in bases:
171 config = getattr(base, 'model_config', None)
172 if config:
173 config_new.update(config.copy())
174
175 config_class_from_namespace = namespace.get('Config')
176 config_dict_from_namespace = namespace.get('model_config')
177
178 if config_class_from_namespace and config_dict_from_namespace:
179 raise PydanticUserError('"Config" and "model_config" cannot be used together', code='config-both')
180
181 config_from_namespace = config_dict_from_namespace or prepare_config(config_class_from_namespace)
182
183 config_new.update(config_from_namespace)
184
185 for k in list(kwargs.keys()):
186 if k in config_keys:
187 config_new[k] = kwargs.pop(k)
188
189 return cls(config_new)
190
191 # we don't show `__getattr__` to type checkers so missing attributes cause errors
192 if not TYPE_CHECKING: # pragma: no branch
193
194 def __getattr__(self, name: str) -> Any:
195 try:
196 return self.effective_config[name]
197 except KeyError:
198 try:
199 return config_defaults[name]
200 except KeyError:
201 raise AttributeError(f'Config has no attribute {name!r}') from None
202
203 def core_config(self, title: str | None) -> core_schema.CoreConfig:
204 """Create a pydantic-core config.
205
206 We don't use getattr here since we don't want to populate with defaults.
207
208 Args:
209 title: The title to use if not set in config.
210
211 Returns:
212 A `CoreConfig` object created from config.
213 """
214 config = self.effective_config
215
216 if not config:
217 # Fast path for the common case of an empty (default) config:
218 return core_schema.CoreConfig(title=title) if title else core_schema.CoreConfig()
219
220 core_config_values: dict[str, Any] = {}
221 for k, v in config.items():
222 if v is not None and (core_config_key := _config_dict_to_core_config_key.get(k)) is not None:
223 core_config_values[core_config_key] = v
224 title = config.get('title') or title
225 if title:
226 core_config_values['title'] = title
227
228 return cast(core_schema.CoreConfig, core_config_values)
229
230 def __repr__(self):
231 c = ', '.join(f'{k}={v!r}' for k, v in self.config_dict.items())
232 return f'ConfigWrapper({c})'
233
234
235class ConfigWrapperStack:
236 """A stack of `ConfigWrapper` instances."""
237
238 def __init__(self, config_wrapper: ConfigWrapper):
239 self._config_wrapper_stack: list[ConfigWrapper] = [config_wrapper]
240
241 @property
242 def tail(self) -> ConfigWrapper:
243 return self._config_wrapper_stack[-1]
244
245 @contextmanager
246 def push(self, config_wrapper: ConfigWrapper | ConfigDict | None):
247 if config_wrapper is None:
248 yield
249 return
250
251 if not isinstance(config_wrapper, ConfigWrapper):
252 config_wrapper = ConfigWrapper(config_wrapper, check=False)
253
254 self._config_wrapper_stack.append(config_wrapper)
255 try:
256 yield
257 finally:
258 self._config_wrapper_stack.pop()
259
260
261config_defaults = ConfigDict(
262 title=None,
263 str_to_lower=False,
264 str_to_upper=False,
265 str_strip_whitespace=False,
266 str_min_length=0,
267 str_max_length=None,
268 # let the model / dataclass decide how to handle it
269 extra=None,
270 frozen=False,
271 populate_by_name=False,
272 use_enum_values=False,
273 validate_assignment=False,
274 arbitrary_types_allowed=False,
275 from_attributes=False,
276 loc_by_alias=True,
277 alias_generator=None,
278 model_title_generator=None,
279 field_title_generator=None,
280 ignored_types=(),
281 allow_inf_nan=True,
282 json_schema_extra=None,
283 strict=False,
284 revalidate_instances='never',
285 ser_json_timedelta='iso8601',
286 ser_json_temporal='iso8601',
287 val_temporal_unit='infer',
288 ser_json_bytes='utf8',
289 val_json_bytes='utf8',
290 ser_json_inf_nan='null',
291 validate_default=False,
292 validate_return=False,
293 protected_namespaces=('model_validate', 'model_dump'),
294 hide_input_in_errors=False,
295 json_encoders=None,
296 defer_build=False,
297 schema_generator=None,
298 plugin_settings=None,
299 json_schema_serialization_defaults_required=False,
300 json_schema_mode_override=None,
301 coerce_numbers_to_str=False,
302 regex_engine='rust-regex',
303 validation_error_cause=False,
304 use_attribute_docstrings=False,
305 cache_strings=True,
306 validate_by_alias=True,
307 validate_by_name=False,
308 serialize_by_alias=False,
309 url_preserve_empty_path=False,
310 polymorphic_serialization=False,
311)
312
313
314def _build_effective_config(config: ConfigDict) -> ConfigDict:
315 """Build the configuration in effect from the configuration as declared.
316
317 Raises:
318 PydanticUserError: If validation can happen by neither alias nor name.
319 """
320 if config.get('validate_by_name') is None:
321 if (populate_by_name := config.get('populate_by_name')) is not None:
322 config = cast(ConfigDict, {**config, 'validate_by_alias': True, 'validate_by_name': populate_by_name})
323 elif config.get('validate_by_alias') is False:
324 config = cast(ConfigDict, {**config, 'validate_by_name': True})
325
326 if (not config.get('validate_by_alias', True)) and (not config.get('validate_by_name', False)):
327 raise PydanticUserError(
328 'At least one of `validate_by_alias` or `validate_by_name` must be set to True.',
329 code='validate-by-alias-and-name-false',
330 )
331
332 return config
333
334
335def prepare_config(config: ConfigDict | dict[str, Any] | type[Any] | None) -> ConfigDict:
336 """Create a `ConfigDict` instance from an existing dict, a class (e.g. old class-based config) or None.
337
338 Args:
339 config: The input config.
340
341 Returns:
342 A ConfigDict object created from config.
343 """
344 if config is None:
345 return ConfigDict()
346
347 if not isinstance(config, dict):
348 warnings.warn(DEPRECATION_MESSAGE, PydanticDeprecatedSince20, stacklevel=4)
349 config = {k: getattr(config, k) for k in dir(config) if not k.startswith('__')}
350 else:
351 # Copy, so that the `ConfigWrapper` owns its configuration and can never
352 # mutate the mapping provided by the user:
353 config = config.copy()
354
355 config_dict = cast(ConfigDict, config)
356 check_deprecated(config_dict)
357 return config_dict
358
359
360config_keys = set(ConfigDict.__annotations__.keys())
361
362
363V2_REMOVED_KEYS = {
364 'allow_mutation',
365 'error_msg_templates',
366 'fields',
367 'getter_dict',
368 'smart_union',
369 'underscore_attrs_are_private',
370 'json_loads',
371 'json_dumps',
372 'copy_on_model_validation',
373 'post_init_call',
374}
375V2_RENAMED_KEYS = {
376 'allow_population_by_field_name': 'validate_by_name',
377 'anystr_lower': 'str_to_lower',
378 'anystr_strip_whitespace': 'str_strip_whitespace',
379 'anystr_upper': 'str_to_upper',
380 'keep_untouched': 'ignored_types',
381 'max_anystr_length': 'str_max_length',
382 'min_anystr_length': 'str_min_length',
383 'orm_mode': 'from_attributes',
384 'schema_extra': 'json_schema_extra',
385 'validate_all': 'validate_default',
386}
387
388
389def check_deprecated(config_dict: ConfigDict) -> None:
390 """Check for deprecated config keys and warn the user.
391
392 Args:
393 config_dict: The input config.
394 """
395 if config_dict.get('schema_generator') is not None:
396 warnings.warn(
397 'The `schema_generator` setting has been deprecated since v2.10. This setting no longer has any effect.',
398 PydanticDeprecatedSince210,
399 )
400
401 deprecated_removed_keys = V2_REMOVED_KEYS & config_dict.keys()
402 deprecated_renamed_keys = V2_RENAMED_KEYS.keys() & config_dict.keys()
403 if deprecated_removed_keys or deprecated_renamed_keys:
404 renamings = {k: V2_RENAMED_KEYS[k] for k in sorted(deprecated_renamed_keys)}
405 renamed_bullets = [f'* {k!r} has been renamed to {v!r}' for k, v in renamings.items()]
406 removed_bullets = [f'* {k!r} has been removed' for k in sorted(deprecated_removed_keys)]
407 message = '\n'.join(['Valid config keys have changed in V2:'] + renamed_bullets + removed_bullets)
408 warnings.warn(message, UserWarning)