From 2c83869818fc2153db0471583bddb217775c4b2e Mon Sep 17 00:00:00 2001 From: sydney-runkle Date: Fri, 1 Dec 2023 10:36:57 -0600 Subject: [PATCH 01/12] first pass at AliasGenerator support --- pydantic/__init__.py | 15 ++- pydantic/_internal/_config.py | 3 +- pydantic/_internal/_generate_schema.py | 44 ++++++--- pydantic/aliases.py | 126 +++++++++++++++++++++++++ pydantic/config.py | 10 +- pydantic/fields.py | 55 +---------- tests/test_aliases.py | 3 +- 7 files changed, 178 insertions(+), 78 deletions(-) create mode 100644 pydantic/aliases.py diff --git a/pydantic/__init__.py b/pydantic/__init__.py index e16fd57495..1759dccc99 100644 --- a/pydantic/__init__.py +++ b/pydantic/__init__.py @@ -17,10 +17,11 @@ from . import dataclasses from ._internal._generate_schema import GenerateSchema as GenerateSchema + from .aliases import AliasChoices, AliasGenerator, AliasPath from .annotated_handlers import GetCoreSchemaHandler, GetJsonSchemaHandler from .config import ConfigDict from .errors import * - from .fields import AliasChoices, AliasPath, Field, PrivateAttr, computed_field + from .fields import Field, PrivateAttr, computed_field from .functional_serializers import ( PlainSerializer, SerializeAsAny, @@ -92,11 +93,13 @@ 'PydanticUndefinedAnnotation', 'PydanticInvalidForJsonSchema', # fields - 'AliasPath', - 'AliasChoices', 'Field', 'computed_field', 'PrivateAttr', + # alias + 'AliasChoices', + 'AliasGenerator', + 'AliasPath', # main 'BaseModel', 'create_model', @@ -240,11 +243,13 @@ 'PydanticUndefinedAnnotation': (__package__, '.errors'), 'PydanticInvalidForJsonSchema': (__package__, '.errors'), # fields - 'AliasPath': (__package__, '.fields'), - 'AliasChoices': (__package__, '.fields'), 'Field': (__package__, '.fields'), 'computed_field': (__package__, '.fields'), 'PrivateAttr': (__package__, '.fields'), + # alias + 'AliasChoices': (__package__, '.aliases'), + 'AliasGenerator': (__package__, '.aliases'), + 'AliasPath': (__package__, '.aliases'), # main 'BaseModel': (__package__, '.main'), 'create_model': (__package__, '.main'), diff --git a/pydantic/_internal/_config.py b/pydantic/_internal/_config.py index 3d45bf6061..52c4cc429d 100644 --- a/pydantic/_internal/_config.py +++ b/pydantic/_internal/_config.py @@ -15,6 +15,7 @@ Self, ) +from ..aliases import AliasGenerator from ..config import ConfigDict, ExtraValues, JsonDict, JsonEncoder, JsonSchemaExtraCallable from ..errors import PydanticUserError from ..warnings import PydanticDeprecatedSince20 @@ -55,7 +56,7 @@ class ConfigWrapper: # whether to use the actual key provided in the data (e.g. alias or first alias for "field required" errors) instead of field_names # to construct error `loc`s, default `True` loc_by_alias: bool - alias_generator: Callable[[str], str] | None + alias_generator: Callable[[str], str] | AliasGenerator | None ignored_types: tuple[type, ...] allow_inf_nan: bool json_schema_extra: JsonDict | JsonSchemaExtraCallable | None diff --git a/pydantic/_internal/_generate_schema.py b/pydantic/_internal/_generate_schema.py index f52b6ac0cc..06be1aa1d0 100644 --- a/pydantic/_internal/_generate_schema.py +++ b/pydantic/_internal/_generate_schema.py @@ -37,6 +37,7 @@ from pydantic_core import CoreSchema, PydanticUndefined, core_schema, to_jsonable_python from typing_extensions import Annotated, Literal, TypeAliasType, TypedDict, get_args, get_origin, is_typeddict +from ..aliases import AliasGenerator from ..annotated_handlers import GetCoreSchemaHandler, GetJsonSchemaHandler from ..config import ConfigDict, JsonDict, JsonEncoder from ..errors import PydanticSchemaGenerationError, PydanticUndefinedAnnotation, PydanticUserError @@ -925,7 +926,8 @@ def _common_field_schema( # noqa C901 self, name: str, field_info: FieldInfo, decorators: DecoratorInfos ) -> _CommonField: # Update FieldInfo annotation if appropriate: - from ..fields import AliasChoices, AliasPath, FieldInfo + from .. import AliasChoices, AliasPath + from ..fields import FieldInfo if has_instance_in_type(field_info.annotation, (ForwardRef, str)): types_namespace = self._types_namespace @@ -1002,21 +1004,35 @@ def set_discriminator(schema: CoreSchema) -> CoreSchema: # apply alias generator alias_generator = self._config_wrapper.alias_generator if alias_generator and ( - field_info.alias_priority is None or field_info.alias_priority <= 1 or field_info.alias is None + field_info.alias_priority is None + or field_info.alias_priority <= 1 + or field_info.alias is None + or field_info.validation_alias is None + or field_info.serialization_alias is None ): - alias = alias_generator(name) - if not isinstance(alias, str): - raise TypeError(f'alias_generator {alias_generator} must return str, not {alias.__class__}') - if field_info.alias is None: - if field_info.serialization_alias is None: - field_info.serialization_alias = alias - if field_info.validation_alias is None: - field_info.validation_alias = alias - else: - field_info.serialization_alias = alias - field_info.validation_alias = alias + validation_alias, alias, serialization_alias = None, None, None + + if isinstance(alias_generator, AliasGenerator): + validation_alias, alias, serialization_alias = alias_generator(name) + elif isinstance(alias_generator, Callable): + alias = alias_generator(name) + if not isinstance(alias, str): + raise TypeError(f'alias_generator {alias_generator} must return str, not {alias.__class__}') + + if field_info.alias_priority is None or field_info.alias_priority <= 1: field_info.alias_priority = 1 - field_info.alias = alias + + if field_info.alias_priority == 1: + field_info.serialization_alias = serialization_alias or alias + field_info.validation_alias = validation_alias or alias + field_info.alias = alias + + if field_info.alias is None: + field_info.alias = alias + if field_info.serialization_alias is None: + field_info.serialization_alias = serialization_alias or alias + if field_info.validation_alias is None: + field_info.validation_alias = validation_alias or alias if isinstance(field_info.validation_alias, (AliasChoices, AliasPath)): validation_alias = field_info.validation_alias.convert_to_aliases() diff --git a/pydantic/aliases.py b/pydantic/aliases.py new file mode 100644 index 0000000000..922d2f4d82 --- /dev/null +++ b/pydantic/aliases.py @@ -0,0 +1,126 @@ +"""Support for alias configurations.""" +from __future__ import annotations + +import dataclasses +from typing import Callable + +from ._internal import _internal_dataclass + +__all__ = ('AliasGenerator', 'AliasPath', 'AliasChoices') + + +@dataclasses.dataclass(**_internal_dataclass.slots_true) +class AliasGenerator: + """Usage docs: https://docs.pydantic.dev/2.6/concepts/alias#alias-generator + + A data class used by `alias_generator` as a convenience to create various aliases. + + Attributes: + validation_alias: A callable that takes a field name and returns a validation alias for it. + alias: A callable that takes a field name and returns an alias for it. + serialization_alias: A callable that takes a field name and returns a serialization alias for it. + """ + + validation_alias: Callable[[str], str | AliasPath | AliasChoices] | None = None + alias: Callable[[str], str] | None = None + serialization_alias: Callable[[str], str] | None = None + + def __init__( + self, + alias: Callable[[str], str] | None = None, + *, + validation_alias: Callable[[str], str | AliasPath | AliasChoices] | None = None, + serialization_alias: Callable[[str], str] | None = None, + ) -> None: + """Initialize the alias generator.""" + self.validation_alias = validation_alias + self.alias = alias + self.serialization_alias = serialization_alias + + def __call__( + self, field_name: str + ) -> tuple[str | list[str | int] | list[list[str | int]] | None, str | None, str | None]: + """Generate aliases for validation, serialization, and alias. + + Returns: + A tuple of three aliases - validation, alias, and serialization. + """ + validation_alias, alias, serialization_alias = None, None, None + + if self.validation_alias is not None: + validation_alias = self.validation_alias(field_name) + if validation_alias: + if not isinstance(validation_alias, (str, AliasChoices, AliasPath)): + raise TypeError( + 'Invalid `validation_alias` type. `validation_alias_generator` must produce' + 'a `validation_alias` of type `str`, `AliasChoices`, or `AliasPath`' + ) + if isinstance(validation_alias, (AliasChoices, AliasPath)): + validation_alias = validation_alias.convert_to_aliases() + if self.serialization_alias is not None: + serialization_alias = self.serialization_alias(field_name) + if serialization_alias and not isinstance(serialization_alias, str): + raise TypeError( + 'Invalid `serialization_alias` type. `serialization_alias_generator` must produce' + 'a `serialization_alias` of type `str`' + ) + if self.alias is not None: + alias = self.alias(field_name) + if alias and not isinstance(alias, str): + raise TypeError('Invalid `alias` type. `alias_generator` must produce a `alias` of type `str`') + + return validation_alias, alias, serialization_alias + + +@dataclasses.dataclass(**_internal_dataclass.slots_true) +class AliasPath: + """Usage docs: https://docs.pydantic.dev/2.6/concepts/fields#aliaspath-and-aliaschoices + + A data class used by `validation_alias` as a convenience to create aliases. + + Attributes: + path: A list of string or integer aliases. + """ + + path: list[int | str] + + def __init__(self, first_arg: str, *args: str | int) -> None: + self.path = [first_arg] + list(args) + + def convert_to_aliases(self) -> list[str | int]: + """Converts arguments to a list of string or integer aliases. + + Returns: + The list of aliases. + """ + return self.path + + +@dataclasses.dataclass(**_internal_dataclass.slots_true) +class AliasChoices: + """Usage docs: https://docs.pydantic.dev/2.6/concepts/fields#aliaspath-and-aliaschoices + + A data class used by `validation_alias` as a convenience to create aliases. + + Attributes: + choices: A list containing a string or `AliasPath`. + """ + + choices: list[str | AliasPath] + + def __init__(self, first_choice: str | AliasPath, *choices: str | AliasPath) -> None: + self.choices = [first_choice] + list(choices) + + def convert_to_aliases(self) -> list[list[str | int]]: + """Converts arguments to a list of lists containing string or integer aliases. + + Returns: + The list of aliases. + """ + aliases: list[list[str | int]] = [] + for c in self.choices: + if isinstance(c, AliasPath): + aliases.append(c.convert_to_aliases()) + else: + aliases.append([c]) + return aliases diff --git a/pydantic/config.py b/pydantic/config.py index 190cc96d53..326cf7bcc5 100644 --- a/pydantic/config.py +++ b/pydantic/config.py @@ -6,6 +6,7 @@ from typing_extensions import Literal, TypeAlias, TypedDict from ._migration import getattr_migration +from .aliases import AliasGenerator if TYPE_CHECKING: from ._internal._generate_schema import GenerateSchema as _GenerateSchema @@ -315,9 +316,14 @@ class Model(BaseModel): loc_by_alias: bool """Whether to use the actual key provided in the data (e.g. alias) for error `loc`s rather than the field's name. Defaults to `True`.""" - alias_generator: Callable[[str], str] | None + alias_generator: Callable[[str], str] | AliasGenerator | None """ - A callable that takes a field name and returns an alias for it. + A callable that takes a field name and returns an alias for it + or an instance of [`AliasGenerator`][pydantic.alias_generators.AliasGenerator]. Defaults to `None`. + + When using a callable, the alias generator is used for both validation and serialization. + If you want to use different alias generators for validation and serialization, you can use + [`AliasGenerator`][pydantic.alias_generators.AliasGenerator] instead. If data source field names do not match your code style (e. g. CamelCase fields), you can automatically generate aliases using `alias_generator`: diff --git a/pydantic/fields.py b/pydantic/fields.py index 303c82426a..40819551ef 100644 --- a/pydantic/fields.py +++ b/pydantic/fields.py @@ -17,6 +17,7 @@ from . import types from ._internal import _decorators, _fields, _generics, _internal_dataclass, _repr, _typing_extra, _utils +from .aliases import AliasChoices, AliasPath from .config import JsonDict from .errors import PydanticUserError from .warnings import PydanticDeprecatedSince20 @@ -568,60 +569,6 @@ def __repr_args__(self) -> ReprArgs: yield s, value -@dataclasses.dataclass(**_internal_dataclass.slots_true) -class AliasPath: - """Usage docs: https://docs.pydantic.dev/2.6/concepts/fields#aliaspath-and-aliaschoices - - A data class used by `validation_alias` as a convenience to create aliases. - - Attributes: - path: A list of string or integer aliases. - """ - - path: list[int | str] - - def __init__(self, first_arg: str, *args: str | int) -> None: - self.path = [first_arg] + list(args) - - def convert_to_aliases(self) -> list[str | int]: - """Converts arguments to a list of string or integer aliases. - - Returns: - The list of aliases. - """ - return self.path - - -@dataclasses.dataclass(**_internal_dataclass.slots_true) -class AliasChoices: - """Usage docs: https://docs.pydantic.dev/2.6/concepts/fields#aliaspath-and-aliaschoices - - A data class used by `validation_alias` as a convenience to create aliases. - - Attributes: - choices: A list containing a string or `AliasPath`. - """ - - choices: list[str | AliasPath] - - def __init__(self, first_choice: str | AliasPath, *choices: str | AliasPath) -> None: - self.choices = [first_choice] + list(choices) - - def convert_to_aliases(self) -> list[list[str | int]]: - """Converts arguments to a list of lists containing string or integer aliases. - - Returns: - The list of aliases. - """ - aliases: list[list[str | int]] = [] - for c in self.choices: - if isinstance(c, AliasPath): - aliases.append(c.convert_to_aliases()) - else: - aliases.append([c]) - return aliases - - class _EmptyKwargs(typing_extensions.TypedDict): """This class exists solely to ensure that type checking warns about passing `**extra` in `Field`.""" diff --git a/tests/test_aliases.py b/tests/test_aliases.py index 2b88a73633..bfc492826c 100644 --- a/tests/test_aliases.py +++ b/tests/test_aliases.py @@ -5,8 +5,7 @@ import pytest from dirty_equals import IsStr -from pydantic import BaseModel, ConfigDict, ValidationError -from pydantic.fields import AliasChoices, AliasPath, Field +from pydantic import AliasChoices, AliasPath, BaseModel, ConfigDict, Field, ValidationError def test_alias_generator(): From d97809d6deec1df4397d13a783a05ab08b73fc8b Mon Sep 17 00:00:00 2001 From: sydney-runkle Date: Fri, 1 Dec 2023 11:41:14 -0600 Subject: [PATCH 02/12] cleaning things up a bit --- pydantic/_internal/_generate_schema.py | 9 +- pydantic/aliases.py | 127 ++++++++++++------------- 2 files changed, 71 insertions(+), 65 deletions(-) diff --git a/pydantic/_internal/_generate_schema.py b/pydantic/_internal/_generate_schema.py index 06be1aa1d0..a46545d46d 100644 --- a/pydantic/_internal/_generate_schema.py +++ b/pydantic/_internal/_generate_schema.py @@ -1001,7 +1001,9 @@ def set_discriminator(schema: CoreSchema) -> CoreSchema: js_annotation_functions=[get_json_schema_update_func(json_schema_updates, json_schema_extra)] ) - # apply alias generator + # Apply an alias_generator if + # 1. An alias is not specified + # 2. An alias is specified, but the priority is <= 1 alias_generator = self._config_wrapper.alias_generator if alias_generator and ( field_info.alias_priority is None @@ -1019,14 +1021,19 @@ def set_discriminator(schema: CoreSchema) -> CoreSchema: if not isinstance(alias, str): raise TypeError(f'alias_generator {alias_generator} must return str, not {alias.__class__}') + # if priority is not set, we set to 1 + # which supports the case where the alias_generator from a child class is used + # to generate an alias for a field in a parent class if field_info.alias_priority is None or field_info.alias_priority <= 1: field_info.alias_priority = 1 + # if the priority is 1, then we set the aliases to the generated alias if field_info.alias_priority == 1: field_info.serialization_alias = serialization_alias or alias field_info.validation_alias = validation_alias or alias field_info.alias = alias + # if any of the aliases are not set, then we set them to the corresponding generated alias if field_info.alias is None: field_info.alias = alias if field_info.serialization_alias is None: diff --git a/pydantic/aliases.py b/pydantic/aliases.py index 922d2f4d82..6b353fc11b 100644 --- a/pydantic/aliases.py +++ b/pydantic/aliases.py @@ -2,76 +2,13 @@ from __future__ import annotations import dataclasses -from typing import Callable +from typing import Callable, Literal from ._internal import _internal_dataclass __all__ = ('AliasGenerator', 'AliasPath', 'AliasChoices') -@dataclasses.dataclass(**_internal_dataclass.slots_true) -class AliasGenerator: - """Usage docs: https://docs.pydantic.dev/2.6/concepts/alias#alias-generator - - A data class used by `alias_generator` as a convenience to create various aliases. - - Attributes: - validation_alias: A callable that takes a field name and returns a validation alias for it. - alias: A callable that takes a field name and returns an alias for it. - serialization_alias: A callable that takes a field name and returns a serialization alias for it. - """ - - validation_alias: Callable[[str], str | AliasPath | AliasChoices] | None = None - alias: Callable[[str], str] | None = None - serialization_alias: Callable[[str], str] | None = None - - def __init__( - self, - alias: Callable[[str], str] | None = None, - *, - validation_alias: Callable[[str], str | AliasPath | AliasChoices] | None = None, - serialization_alias: Callable[[str], str] | None = None, - ) -> None: - """Initialize the alias generator.""" - self.validation_alias = validation_alias - self.alias = alias - self.serialization_alias = serialization_alias - - def __call__( - self, field_name: str - ) -> tuple[str | list[str | int] | list[list[str | int]] | None, str | None, str | None]: - """Generate aliases for validation, serialization, and alias. - - Returns: - A tuple of three aliases - validation, alias, and serialization. - """ - validation_alias, alias, serialization_alias = None, None, None - - if self.validation_alias is not None: - validation_alias = self.validation_alias(field_name) - if validation_alias: - if not isinstance(validation_alias, (str, AliasChoices, AliasPath)): - raise TypeError( - 'Invalid `validation_alias` type. `validation_alias_generator` must produce' - 'a `validation_alias` of type `str`, `AliasChoices`, or `AliasPath`' - ) - if isinstance(validation_alias, (AliasChoices, AliasPath)): - validation_alias = validation_alias.convert_to_aliases() - if self.serialization_alias is not None: - serialization_alias = self.serialization_alias(field_name) - if serialization_alias and not isinstance(serialization_alias, str): - raise TypeError( - 'Invalid `serialization_alias` type. `serialization_alias_generator` must produce' - 'a `serialization_alias` of type `str`' - ) - if self.alias is not None: - alias = self.alias(field_name) - if alias and not isinstance(alias, str): - raise TypeError('Invalid `alias` type. `alias_generator` must produce a `alias` of type `str`') - - return validation_alias, alias, serialization_alias - - @dataclasses.dataclass(**_internal_dataclass.slots_true) class AliasPath: """Usage docs: https://docs.pydantic.dev/2.6/concepts/fields#aliaspath-and-aliaschoices @@ -124,3 +61,65 @@ def convert_to_aliases(self) -> list[list[str | int]]: else: aliases.append([c]) return aliases + + +@dataclasses.dataclass(**_internal_dataclass.slots_true) +class AliasGenerator: + """Usage docs: https://docs.pydantic.dev/2.6/concepts/alias#alias-generator + + A data class used by `alias_generator` as a convenience to create various aliases. + + Attributes: + validation_alias: A callable that takes a field name and returns a validation alias for it. + alias: A callable that takes a field name and returns an alias for it. + serialization_alias: A callable that takes a field name and returns a serialization alias for it. + """ + + validation_alias: Callable[[str], str | AliasPath | AliasChoices] | None = None + alias: Callable[[str], str] | None = None + serialization_alias: Callable[[str], str] | None = None + + def __init__( + self, + alias: Callable[[str], str] | None = None, + *, + validation_alias: Callable[[str], str | AliasPath | AliasChoices] | None = None, + serialization_alias: Callable[[str], str] | None = None, + ) -> None: + """Initialize the alias generator.""" + self.validation_alias = validation_alias + self.alias = alias + self.serialization_alias = serialization_alias + + def _generate_alias( + self, + alias_kind: Literal['validation_alias', 'serialization_alias', 'alias'], + allowed_types: tuple[type[str] | type[AliasPath] | type[AliasChoices], ...], + field_name: str, + ) -> str | AliasPath | AliasChoices | None: + """Generate an alias of the specified kind. Returns None if the alias generator is None. + + Raises: + TypeError: If the alias generator produces an invalid type. + """ + alias = None + alias_generator = getattr(self, alias_kind) + if alias_generator is not None: + alias = alias_generator(field_name) + if alias and not isinstance(alias, allowed_types): + raise TypeError( + f'Invalid `{alias_kind}` type. `{alias_kind} generator` must produce' f' one of `{allowed_types}`' + ) + return alias + + def __call__(self, field_name: str) -> tuple[str | AliasPath | AliasChoices | None, str | None, str | None]: + """Generate validation_alias, alias, and serialization_alias for a field via the specified generators. + + Returns: + A tuple of three aliases - validation, alias, and serialization. + """ + validation_alias = self._generate_alias('validation_alias', (str, AliasChoices, AliasPath), field_name) + alias = self._generate_alias('alias', (str,), field_name) + serialization_alias = self._generate_alias('serialization_alias', (str,), field_name) + + return validation_alias, alias, serialization_alias # type: ignore From e013c7eef62688e80201fb455eae39b9b0285b9f Mon Sep 17 00:00:00 2001 From: sydney-runkle Date: Fri, 1 Dec 2023 11:53:48 -0600 Subject: [PATCH 03/12] initial tests --- tests/test_aliases.py | 72 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 63 insertions(+), 9 deletions(-) diff --git a/tests/test_aliases.py b/tests/test_aliases.py index bfc492826c..821132fcdf 100644 --- a/tests/test_aliases.py +++ b/tests/test_aliases.py @@ -5,7 +5,7 @@ import pytest from dirty_equals import IsStr -from pydantic import AliasChoices, AliasPath, BaseModel, ConfigDict, Field, ValidationError +from pydantic import AliasChoices, AliasGenerator, AliasPath, BaseModel, ConfigDict, Field, ValidationError def test_alias_generator(): @@ -183,9 +183,22 @@ class Child(Parent): assert Child.model_fields['x'].alias == 'x2' -def test_alias_generator_on_parent(): +upper_alias_generator = [ + pytest.param( + lambda x: x.upper(), + id='basic_callable', + ), + pytest.param( + AliasGenerator(lambda x: x.upper()), + id='alias_generator', + ), +] + + +@pytest.mark.parametrize('alias_generator', upper_alias_generator) +def test_alias_generator_on_parent(alias_generator): class Parent(BaseModel): - model_config = ConfigDict(alias_generator=lambda x: x.upper()) + model_config = ConfigDict(alias_generator=alias_generator) x: bool = Field(..., alias='a_b_c') y: str @@ -200,13 +213,14 @@ class Child(Parent): assert Child.model_fields['z'].alias == 'Z' -def test_alias_generator_on_child(): +@pytest.mark.parametrize('alias_generator', upper_alias_generator) +def test_alias_generator_on_child(alias_generator): class Parent(BaseModel): x: bool = Field(..., alias='abc') y: str class Child(Parent): - model_config = ConfigDict(alias_generator=lambda x: x.upper()) + model_config = ConfigDict(alias_generator=alias_generator) y: str z: str @@ -215,9 +229,10 @@ class Child(Parent): assert [f.alias for f in Child.model_fields.values()] == ['abc', 'Y', 'Z'] -def test_alias_generator_used_by_default(): +@pytest.mark.parametrize('alias_generator', upper_alias_generator) +def test_alias_generator_used_by_default(alias_generator): class Model(BaseModel): - model_config = ConfigDict(alias_generator=lambda x: x.upper()) + model_config = ConfigDict(alias_generator=alias_generator) a: str b: str = Field(..., alias='b_alias') @@ -273,7 +288,8 @@ class Model(BaseModel): } -def test_low_priority_alias(): +@pytest.mark.parametrize('alias_generator', upper_alias_generator) +def test_low_priority_alias(alias_generator): class Parent(BaseModel): w: bool = Field(..., alias='w_', validation_alias='w_val_alias', serialization_alias='w_ser_alias') x: bool = Field( @@ -282,7 +298,7 @@ class Parent(BaseModel): y: str class Child(Parent): - model_config = ConfigDict(alias_generator=lambda x: x.upper()) + model_config = ConfigDict(alias_generator=alias_generator) y: str z: str @@ -586,3 +602,41 @@ class Model(BaseModel): 'input': {'b': ['hello']}, } ] + + +def test_alias_generator() -> None: + class Model(BaseModel): + a: str + + model_config = ConfigDict( + alias_generator=AliasGenerator( + validation_alias=lambda field_name: f'validation_{field_name}', + serialization_alias=lambda field_name: f'serialization_{field_name}', + ) + ) + + assert Model.model_fields['a'].validation_alias == 'validation_a' + assert Model.model_fields['a'].serialization_alias == 'serialization_a' + assert Model.model_fields['a'].alias is None + + +def test_alias_generator_with_alias() -> None: + class Model(BaseModel): + a: str + + model_config = ConfigDict(alias_generator=AliasGenerator(alias=lambda field_name: f'{field_name}_alias')) + + assert Model.model_fields['a'].validation_alias == 'a_alias' + assert Model.model_fields['a'].serialization_alias == 'a_alias' + assert Model.model_fields['a'].alias == 'a_alias' + + +def test_alias_generator_with_positional_arg() -> None: + class Model(BaseModel): + a: str + + model_config = ConfigDict(alias_generator=AliasGenerator(lambda field_name: f'{field_name}_alias')) + + assert Model.model_fields['a'].validation_alias == 'a_alias' + assert Model.model_fields['a'].serialization_alias == 'a_alias' + assert Model.model_fields['a'].alias == 'a_alias' From bcbdba2c15712547b7aadf5909426fcfd7eeeb44 Mon Sep 17 00:00:00 2001 From: sydney-runkle Date: Fri, 1 Dec 2023 13:07:14 -0600 Subject: [PATCH 04/12] docs updates --- docs/api/aliases.md | 1 + docs/api/fields.md | 2 - docs/concepts/alias.md | 159 +++++++++++++++++++++++++++++++++++++++- docs/concepts/fields.md | 78 +------------------- mkdocs.yml | 1 + pydantic/aliases.py | 6 +- pydantic/config.py | 31 +++++++- 7 files changed, 194 insertions(+), 84 deletions(-) create mode 100644 docs/api/aliases.md diff --git a/docs/api/aliases.md b/docs/api/aliases.md new file mode 100644 index 0000000000..5c29cc687e --- /dev/null +++ b/docs/api/aliases.md @@ -0,0 +1 @@ +::: pydantic.aliases diff --git a/docs/api/fields.md b/docs/api/fields.md index 6bebfcc184..0ecd848297 100644 --- a/docs/api/fields.md +++ b/docs/api/fields.md @@ -4,8 +4,6 @@ members: - Field - FieldInfo - - AliasChoices - - AliasPath - PrivateAttr - ModelPrivateAttr - computed_field diff --git a/docs/concepts/alias.md b/docs/concepts/alias.md index 19074c88ac..ac37c0eaad 100644 --- a/docs/concepts/alias.md +++ b/docs/concepts/alias.md @@ -3,9 +3,164 @@ An alias is an alternative name for a field, used when serializing and deseriali You can specify an alias in the following ways: * `alias` on the [`Field`][pydantic.fields.Field] + * must be a `str` * `validation_alias` on the [`Field`][pydantic.fields.Field] + * can be an instance of `str`, [`AliasPath`][pydantic.aliases.AliasPath], or [`AliasChoices`][pydantic.aliases.AliasChoices] * `serialization_alias` on the [`Field`][pydantic.fields.Field] + * must be a `str` * `alias_generator` on the [`Config`][pydantic.config.ConfigDict.alias_generator] + * can be a callable or an instance of [`AliasGenerator`][pydantic.aliases.AliasGenerator] + +For examples of how to use `alias`, `validation_alias`, and `serialization_alias`, see [Field aliases](../concepts/fields.md#field-aliases). + +## `AliasPath` and `AliasChoices` + +??? api "API Documentation" + + [`pydantic.aliases.AliasPath`][pydantic.aliases.AliasPath]
+ [`pydantic.aliases.AliasChoices`][pydantic.aliases.AliasChoices]
+ +Pydantic provides two special types for convenience when using `validation_alias`: `AliasPath` and `AliasChoices`. + +The `AliasPath` is used to specify a path to a field using aliases. For example: + +```py lint="skip" +from pydantic import BaseModel, Field, AliasPath + + +class User(BaseModel): + first_name: str = Field(validation_alias=AliasPath('names', 0)) + last_name: str = Field(validation_alias=AliasPath('names', 1)) + +user = User.model_validate({'names': ['John', 'Doe']}) # (1)! +print(user) +#> first_name='John' last_name='Doe' +``` + +1. We are using `model_validate` to validate a dictionary using the field aliases. + + You can see more details about [`model_validate`][pydantic.main.BaseModel.model_validate] in the API reference. + +In the `'first_name'` field, we are using the alias `'names'` and the index `0` to specify the path to the first name. +In the `'last_name'` field, we are using the alias `'names'` and the index `1` to specify the path to the last name. + +`AliasChoices` is used to specify a choice of aliases. For example: + +```py lint="skip" +from pydantic import BaseModel, Field, AliasChoices + + +class User(BaseModel): + first_name: str = Field(validation_alias=AliasChoices('first_name', 'fname')) + last_name: str = Field(validation_alias=AliasChoices('last_name', 'lname')) + +user = User.model_validate({'fname': 'John', 'lname': 'Doe'}) # (1)! +print(user) +#> first_name='John' last_name='Doe' +user = User.model_validate({'first_name': 'John', 'lname': 'Doe'}) # (2)! +print(user) +#> first_name='John' last_name='Doe' +``` + +1. We are using the second alias choice for both fields. +2. We are using the first alias choice for the field `'first_name'` and the second alias choice + for the field `'last_name'`. + +You can also use `AliasChoices` with `AliasPath`: + +```py lint="skip" +from pydantic import BaseModel, Field, AliasPath, AliasChoices + + +class User(BaseModel): + first_name: str = Field(validation_alias=AliasChoices('first_name', AliasPath('names', 0))) + last_name: str = Field(validation_alias=AliasChoices('last_name', AliasPath('names', 1))) + + +user = User.model_validate({'first_name': 'John', 'last_name': 'Doe'}) +print(user) +#> first_name='John' last_name='Doe' +user = User.model_validate({'names': ['John', 'Doe']}) +print(user) +#> first_name='John' last_name='Doe' +user = User.model_validate({'names': ['John'], 'last_name': 'Doe'}) +print(user) +#> first_name='John' last_name='Doe' +``` + +## Using alias generators + +You can use the `alias_generator` parameter of [`Config`][pydantic.config.ConfigDict.alias_generator] to specify +a callable (or group of callables, via `AliasGenerator`) that will generate aliases for all fields in a model. +This is useful if you want to use a consistent naming convention for all fields in a model, but doin't +want to specify the alias for each field individually. + +Note: + Pydantic offers three built-in alias generators that you can use out of the box: + + * [`to_pascal`][pydantic.alias_generators.to_pascal] + * [`to_camel`][pydantic.alias_generators.to_camel] + * [`to_snake`][pydantic.alias_generators.to_snake] + +### Using a callable + +Here's a basic example using a callable: + +```py +from pydantic import BaseModel, ConfigDict + + +class Tree(BaseModel): + model_config = ConfigDict( + alias_generator=lambda field_name: field_name.upper() + ) + + age: int + height: float + kind: str + + +t = Tree.model_validate({'AGE': 12, 'HEIGHT': 1.2, 'KIND': 'oak'}) +print(t.model_dump(by_alias=True)) +#> {'AGE': 12, 'HEIGHT': 1.2, 'KIND': 'oak'} +``` + +### Using an `AliasGenerator` + +??? api "API Documentation" + + [`pydantic.aliases.AliasGenerator`][pydantic.aliases.AliasGenerator]
+ + +`AliasGenerator` is a class that allows you to specify multiple alias generators for a model. +You can use an `AliasGenerator` to specify different alias generators for validation and serialization. + +This is particularly useful if you need to use different naming conventions for loading and saving data, +but you don't want to specify the validation and serialization aliases for each field individually. + +For example: + +```py +from pydantic import AliasGenerator, BaseModel, ConfigDict + + +class Tree(BaseModel): + model_config = ConfigDict( + alias_generator=AliasGenerator( + validation_alias=lambda field_name: field_name.upper(), + serialization_alias=lambda field_name: field_name.title(), + ) + ) + + age: int + height: float + kind: str + + +t = Tree.model_validate({'AGE': 12, 'HEIGHT': 1.2, 'KIND': 'oak'}) +print(t.model_dump(by_alias=True)) +#> {'Age': 12, 'Height': 1.2, 'Kind': 'oak'} +``` ## Alias Precedence @@ -39,7 +194,9 @@ You may set `alias_priority` on a field to change this behavior: * `alias_priority=2` the alias will *not* be overridden by the alias generator. * `alias_priority=1` the alias *will* be overridden by the alias generator. -* `alias_priority` not set, the alias will be overridden by the alias generator. +* `alias_priority` not set: + * alias is set: the alias will *not* be overridden by the alias generator. + * alias is not set: the alias *will* be overridden by the alias generator. The same precedence applies to `validation_alias` and `serialization_alias`. See more about the different field aliases under [field aliases](../concepts/fields.md#field-aliases). diff --git a/docs/concepts/fields.md b/docs/concepts/fields.md index 8195f77d25..0a354141c3 100644 --- a/docs/concepts/fields.md +++ b/docs/concepts/fields.md @@ -73,7 +73,7 @@ The `alias` parameter is used for both validation _and_ serialization. If you wa _different_ aliases for validation and serialization respectively, you can use the`validation_alias` and `serialization_alias` parameters, which will apply only in their respective use cases. -Here is some example usage of the `alias` parameter: +Here is an example of using the `alias` parameter: ```py from pydantic import BaseModel, Field @@ -245,80 +245,7 @@ print(user.model_dump(by_alias=True)) # (2)! [`@typing.dataclass_transform`](https://docs.python.org/3/library/typing.html#typing.dataclass_transform) decorator, such as Pyright. -### `AliasPath` and `AliasChoices` - -??? api "API Documentation" - - [`pydantic.fields.AliasPath`][pydantic.fields.AliasPath]
- [`pydantic.fields.AliasChoices`][pydantic.fields.AliasChoices]
- -Pydantic provides two special types for convenience when using `validation_alias`: `AliasPath` and `AliasChoices`. - -The `AliasPath` is used to specify a path to a field using aliases. For example: - -```py lint="skip" -from pydantic import BaseModel, Field, AliasPath - - -class User(BaseModel): - first_name: str = Field(validation_alias=AliasPath('names', 0)) - last_name: str = Field(validation_alias=AliasPath('names', 1)) - -user = User.model_validate({'names': ['John', 'Doe']}) # (1)! -print(user) -#> first_name='John' last_name='Doe' -``` - -1. We are using `model_validate` to validate a dictionary using the field aliases. - - You can see more details about [`model_validate`][pydantic.main.BaseModel.model_validate] in the API reference. - -In the `'first_name'` field, we are using the alias `'names'` and the index `0` to specify the path to the first name. -In the `'last_name'` field, we are using the alias `'names'` and the index `1` to specify the path to the last name. - -`AliasChoices` is used to specify a choice of aliases. For example: - -```py lint="skip" -from pydantic import BaseModel, Field, AliasChoices - - -class User(BaseModel): - first_name: str = Field(validation_alias=AliasChoices('first_name', 'fname')) - last_name: str = Field(validation_alias=AliasChoices('last_name', 'lname')) - -user = User.model_validate({'fname': 'John', 'lname': 'Doe'}) # (1)! -print(user) -#> first_name='John' last_name='Doe' -user = User.model_validate({'first_name': 'John', 'lname': 'Doe'}) # (2)! -print(user) -#> first_name='John' last_name='Doe' -``` - -1. We are using the second alias choice for both fields. -2. We are using the first alias choice for the field `'first_name'` and the second alias choice - for the field `'last_name'`. - -You can also use `AliasChoices` with `AliasPath`: - -```py lint="skip" -from pydantic import BaseModel, Field, AliasPath, AliasChoices - - -class User(BaseModel): - first_name: str = Field(validation_alias=AliasChoices('first_name', AliasPath('names', 0))) - last_name: str = Field(validation_alias=AliasChoices('last_name', AliasPath('names', 1))) - - -user = User.model_validate({'first_name': 'John', 'last_name': 'Doe'}) -print(user) -#> first_name='John' last_name='Doe' -user = User.model_validate({'names': ['John', 'Doe']}) -print(user) -#> first_name='John' last_name='Doe' -user = User.model_validate({'names': ['John'], 'last_name': 'Doe'}) -print(user) -#> first_name='John' last_name='Doe' -``` +For more information on alias usage, see the [Alias] concepts page. ## Numeric Constraints @@ -816,3 +743,4 @@ print(b.model_dump()) [Serialization]: serialization.md#model-and-field-level-include-and-exclude [Customizing JSON Schema]: json_schema.md#field-level-customization [annotated]: https://docs.python.org/3/library/typing.html#typing.Annotated +[Alias]: ../concepts/alias.md diff --git a/mkdocs.yml b/mkdocs.yml index 13d1b8515f..db74da09ef 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -88,6 +88,7 @@ nav: - TypeAdapter: api/type_adapter.md - Validate Call: api/validate_call.md - Fields: api/fields.md + - Aliases: api/aliases.md - Configuration: api/config.md - JSON Schema: api/json_schema.md - Errors: api/errors.md diff --git a/pydantic/aliases.py b/pydantic/aliases.py index 6b353fc11b..0fb22e71e5 100644 --- a/pydantic/aliases.py +++ b/pydantic/aliases.py @@ -11,7 +11,7 @@ @dataclasses.dataclass(**_internal_dataclass.slots_true) class AliasPath: - """Usage docs: https://docs.pydantic.dev/2.6/concepts/fields#aliaspath-and-aliaschoices + """Usage docs: https://docs.pydantic.dev/2.6/concepts/alias#aliaspath-and-aliaschoices A data class used by `validation_alias` as a convenience to create aliases. @@ -35,7 +35,7 @@ def convert_to_aliases(self) -> list[str | int]: @dataclasses.dataclass(**_internal_dataclass.slots_true) class AliasChoices: - """Usage docs: https://docs.pydantic.dev/2.6/concepts/fields#aliaspath-and-aliaschoices + """Usage docs: https://docs.pydantic.dev/2.6/concepts/alias#aliaspath-and-aliaschoices A data class used by `validation_alias` as a convenience to create aliases. @@ -65,7 +65,7 @@ def convert_to_aliases(self) -> list[list[str | int]]: @dataclasses.dataclass(**_internal_dataclass.slots_true) class AliasGenerator: - """Usage docs: https://docs.pydantic.dev/2.6/concepts/alias#alias-generator + """Usage docs: https://docs.pydantic.dev/2.6/concepts/alias#using-an-aliasgenerator A data class used by `alias_generator` as a convenience to create various aliases. diff --git a/pydantic/config.py b/pydantic/config.py index 326cf7bcc5..069e01554e 100644 --- a/pydantic/config.py +++ b/pydantic/config.py @@ -319,14 +319,15 @@ class Model(BaseModel): alias_generator: Callable[[str], str] | AliasGenerator | None """ A callable that takes a field name and returns an alias for it - or an instance of [`AliasGenerator`][pydantic.alias_generators.AliasGenerator]. Defaults to `None`. + or an instance of [`AliasGenerator`][pydantic.aliases.AliasGenerator]. Defaults to `None`. When using a callable, the alias generator is used for both validation and serialization. If you want to use different alias generators for validation and serialization, you can use - [`AliasGenerator`][pydantic.alias_generators.AliasGenerator] instead. + [`AliasGenerator`][pydantic.aliases.AliasGenerator] instead. If data source field names do not match your code style (e. g. CamelCase fields), - you can automatically generate aliases using `alias_generator`: + you can automatically generate aliases using `alias_generator`. Here's an example with + a basic callable: ```py from pydantic import BaseModel, ConfigDict @@ -345,6 +346,30 @@ class Voice(BaseModel): #> {'Name': 'Filiz', 'LanguageCode': 'tr-TR'} ``` + If you want to use different alias generators for validation and serialization, you can use + [`AliasGenerator`][pydantic.aliases.AliasGenerator]. + + ```py + from pydantic import AliasGenerator, BaseModel, ConfigDict + from pydantic.alias_generators import to_camel, to_pascal + + class Athlete(BaseModel): + first_name: str + last_name: str + sport: str + + model_config = ConfigDict( + alias_generator=AliasGenerator( + validation_alias=to_camel, + serialization_alias=to_pascal, + ) + ) + + athlete = Athlete(firstName='John', lastName='Doe', sport='track') + print(athlete.model_dump(by_alias=True)) + #> {'FirstName': 'John', 'LastName': 'Doe', 'Sport': 'track'} + ``` + Note: Pydantic offers three built-in alias generators: [`to_pascal`][pydantic.alias_generators.to_pascal], [`to_camel`][pydantic.alias_generators.to_camel], and [`to_snake`][pydantic.alias_generators.to_snake]. From 6d6346b394ad255be8554d082ec6baa20da2394f Mon Sep 17 00:00:00 2001 From: sydney-runkle Date: Fri, 1 Dec 2023 14:49:08 -0600 Subject: [PATCH 05/12] computed field support --- pdm.lock | 37 ++++----- pydantic/_internal/_generate_schema.py | 104 +++++++++++++++---------- pydantic/aliases.py | 7 +- tests/test_aliases.py | 28 ++++++- 4 files changed, 111 insertions(+), 65 deletions(-) diff --git a/pdm.lock b/pdm.lock index 9cfdb31fc9..feb9788210 100644 --- a/pdm.lock +++ b/pdm.lock @@ -427,15 +427,15 @@ files = [ [[package]] name = "dirty-equals" -version = "0.6.0" +version = "0.7.1.post0" requires_python = ">=3.7" summary = "Doing dirty (but extremely useful) things with equals." dependencies = [ "pytz>=2021.3", ] files = [ - {file = "dirty_equals-0.6.0-py3-none-any.whl", hash = "sha256:7c29af40193a862ce66f932236c2a4be97489bbf7caf8a90e4a606e7c47c41b3"}, - {file = "dirty_equals-0.6.0.tar.gz", hash = "sha256:4c4e4b9b52670ad8b880c46734e5ffc52e023250ae817398b78b30e329c3955d"}, + {file = "dirty_equals-0.7.1.post0-py3-none-any.whl", hash = "sha256:7fb9217ea7cd04c0e95ace3bc717e2ee5532b8990518533483e53b5a43903c88"}, + {file = "dirty_equals-0.7.1.post0.tar.gz", hash = "sha256:78ff80578a46163831ecb3255cf30d03d1dc2fbca8e67f820105691a1bc556dc"}, ] [[package]] @@ -484,15 +484,16 @@ files = [ [[package]] name = "faker" -version = "18.13.0" -requires_python = ">=3.7" +version = "20.1.0" +requires_python = ">=3.8" summary = "Faker is a Python package that generates fake data for you." dependencies = [ "python-dateutil>=2.4", + "typing-extensions>=3.10.0.1; python_version <= \"3.8\"", ] files = [ - {file = "Faker-18.13.0-py3-none-any.whl", hash = "sha256:801d1a2d71f1fc54d332de2ab19de7452454309937233ea2f7485402882d67b3"}, - {file = "Faker-18.13.0.tar.gz", hash = "sha256:84bcf92bb725dd7341336eea4685df9a364f16f2470c4d29c1d7e6c5fd5a457d"}, + {file = "Faker-20.1.0-py3-none-any.whl", hash = "sha256:aeb3e26742863d1e387f9d156f1c36e14af63bf5e6f36fb39b8c27f6a903be38"}, + {file = "Faker-20.1.0.tar.gz", hash = "sha256:562a3a09c3ed3a1a7b20e13d79f904dfdfc5e740f72813ecf95e4cf71e5a2f52"}, ] [[package]] @@ -876,7 +877,7 @@ files = [ [[package]] name = "mkdocs-material" -version = "9.4.8" +version = "9.4.14" requires_python = ">=3.8" summary = "Documentation that simply works" dependencies = [ @@ -893,8 +894,8 @@ dependencies = [ "requests~=2.26", ] files = [ - {file = "mkdocs_material-9.4.8-py3-none-any.whl", hash = "sha256:8b20f6851bddeef37dced903893cd176cf13a21a482e97705a103c45f06ce9b9"}, - {file = "mkdocs_material-9.4.8.tar.gz", hash = "sha256:f0c101453e8bc12b040e8b64ca39a405d950d8402609b1378cc2b98976e74b5f"}, + {file = "mkdocs_material-9.4.14-py3-none-any.whl", hash = "sha256:dbc78a4fea97b74319a6aa9a2f0be575a6028be6958f813ba367188f7b8428f6"}, + {file = "mkdocs_material-9.4.14.tar.gz", hash = "sha256:a511d3ff48fa8718b033e7e37d17abd9cc1de0fdf0244a625ca2ae2387e2416d"}, ] [[package]] @@ -953,7 +954,7 @@ files = [ [[package]] name = "mkdocstrings-python" -version = "1.7.4" +version = "1.7.5" requires_python = ">=3.8" summary = "A Python handler for mkdocstrings." dependencies = [ @@ -961,8 +962,8 @@ dependencies = [ "mkdocstrings>=0.20", ] files = [ - {file = "mkdocstrings_python-1.7.4-py3-none-any.whl", hash = "sha256:70eacbe5f2d5071f2e525ba0b35bc447d398437dfbcd90c63fe6e977551cfe26"}, - {file = "mkdocstrings_python-1.7.4.tar.gz", hash = "sha256:c2fc34efd70000ec31aee247910006e8dd9d1b9f3957bf46880c3f6e51a8f0d5"}, + {file = "mkdocstrings_python-1.7.5-py3-none-any.whl", hash = "sha256:5f6246026353f0c0785135db70c3fe9a5d9318990fc7ceb11d62097b8ffdd704"}, + {file = "mkdocstrings_python-1.7.5.tar.gz", hash = "sha256:c7d143728257dbf1aa550446555a554b760dcd40a763f077189d298502b800be"}, ] [[package]] @@ -1195,16 +1196,16 @@ dependencies = [ [[package]] name = "pydantic-settings" -version = "2.0.3" -requires_python = ">=3.7" +version = "2.1.0" +requires_python = ">=3.8" summary = "Settings management using Pydantic" dependencies = [ - "pydantic>=2.0.1", + "pydantic>=2.3.0", "python-dotenv>=0.21.0", ] files = [ - {file = "pydantic_settings-2.0.3-py3-none-any.whl", hash = "sha256:ddd907b066622bd67603b75e2ff791875540dc485b7307c4fffc015719da8625"}, - {file = "pydantic_settings-2.0.3.tar.gz", hash = "sha256:962dc3672495aad6ae96a4390fac7e593591e144625e5112d359f8f67fb75945"}, + {file = "pydantic_settings-2.1.0-py3-none-any.whl", hash = "sha256:7621c0cb5d90d1140d2f0ef557bdf03573aac7035948109adf2574770b77605a"}, + {file = "pydantic_settings-2.1.0.tar.gz", hash = "sha256:26b1492e0a24755626ac5e6d715e9077ab7ad4fb5f19a8b7ed7011d52f36141c"}, ] [[package]] diff --git a/pydantic/_internal/_generate_schema.py b/pydantic/_internal/_generate_schema.py index a46545d46d..ab4538def9 100644 --- a/pydantic/_internal/_generate_schema.py +++ b/pydantic/_internal/_generate_schema.py @@ -454,7 +454,7 @@ def generate_schema( PydanticSchemaGenerationError: If it is not possible to generate pydantic-core schema. TypeError: - - If `alias_generator` returns a non-string value. + - If `alias_generator` returns a disallowed type (must be str, AliasPath or AliasChoices). - If V1 style validator with `each_item=True` applied on a wrong field. PydanticUserError: - If `typing.TypedDict` is used instead of `typing_extensions.TypedDict` on Python < 3.12. @@ -922,7 +922,57 @@ def _generate_dc_field_schema( metadata=common_field['metadata'], ) - def _common_field_schema( # noqa C901 + @staticmethod + def _apply_alias_generator( + alias_generator: Callable[[str], str] | AliasGenerator, field_info: FieldInfo, field_name: str + ) -> None: + """Apply an alias_generator to aliases on a FieldInfo instance if appropriate. + + Args: + alias_generator: A callable that takes a string and returns a string, or an AliasGenerator instance. + field_info: The FieldInfo instance to which the alias_generator is (maybe) applied. + field_name: The name of the field from which to generate the alias. + """ + # Apply an alias_generator if + # 1. An alias is not specified + # 2. An alias is specified, but the priority is <= 1 + if alias_generator and ( + field_info.alias_priority is None + or field_info.alias_priority <= 1 + or field_info.alias is None + or field_info.validation_alias is None + or field_info.serialization_alias is None + ): + validation_alias, alias, serialization_alias = None, None, None + + if isinstance(alias_generator, AliasGenerator): + validation_alias, alias, serialization_alias = alias_generator.generate_aliases(field_name) + elif isinstance(alias_generator, Callable): + alias = alias_generator(field_name) + if not isinstance(alias, str): + raise TypeError(f'alias_generator {alias_generator} must return str, not {alias.__class__}') + + # if priority is not set, we set to 1 + # which supports the case where the alias_generator from a child class is used + # to generate an alias for a field in a parent class + if field_info.alias_priority is None or field_info.alias_priority <= 1: + field_info.alias_priority = 1 + + # if the priority is 1, then we set the aliases to the generated alias + if field_info.alias_priority == 1: + field_info.serialization_alias = serialization_alias or alias + field_info.validation_alias = validation_alias or alias + field_info.alias = alias + + # if any of the aliases are not set, then we set them to the corresponding generated alias + if field_info.alias is None: + field_info.alias = alias + if field_info.serialization_alias is None: + field_info.serialization_alias = serialization_alias or alias + if field_info.validation_alias is None: + field_info.validation_alias = validation_alias or alias + + def _common_field_schema( # C901 self, name: str, field_info: FieldInfo, decorators: DecoratorInfos ) -> _CommonField: # Update FieldInfo annotation if appropriate: @@ -1001,45 +1051,9 @@ def set_discriminator(schema: CoreSchema) -> CoreSchema: js_annotation_functions=[get_json_schema_update_func(json_schema_updates, json_schema_extra)] ) - # Apply an alias_generator if - # 1. An alias is not specified - # 2. An alias is specified, but the priority is <= 1 alias_generator = self._config_wrapper.alias_generator - if alias_generator and ( - field_info.alias_priority is None - or field_info.alias_priority <= 1 - or field_info.alias is None - or field_info.validation_alias is None - or field_info.serialization_alias is None - ): - validation_alias, alias, serialization_alias = None, None, None - - if isinstance(alias_generator, AliasGenerator): - validation_alias, alias, serialization_alias = alias_generator(name) - elif isinstance(alias_generator, Callable): - alias = alias_generator(name) - if not isinstance(alias, str): - raise TypeError(f'alias_generator {alias_generator} must return str, not {alias.__class__}') - - # if priority is not set, we set to 1 - # which supports the case where the alias_generator from a child class is used - # to generate an alias for a field in a parent class - if field_info.alias_priority is None or field_info.alias_priority <= 1: - field_info.alias_priority = 1 - - # if the priority is 1, then we set the aliases to the generated alias - if field_info.alias_priority == 1: - field_info.serialization_alias = serialization_alias or alias - field_info.validation_alias = validation_alias or alias - field_info.alias = alias - - # if any of the aliases are not set, then we set them to the corresponding generated alias - if field_info.alias is None: - field_info.alias = alias - if field_info.serialization_alias is None: - field_info.serialization_alias = serialization_alias or alias - if field_info.validation_alias is None: - field_info.validation_alias = validation_alias or alias + if alias_generator is not None: + self._apply_alias_generator(alias_generator, field_info, name) if isinstance(field_info.validation_alias, (AliasChoices, AliasPath)): validation_alias = field_info.validation_alias.convert_to_aliases() @@ -1270,7 +1284,9 @@ def _generate_parameter_schema( parameter_schema['alias'] = field.alias else: alias_generator = self._config_wrapper.alias_generator - if alias_generator: + if isinstance(alias_generator, AliasGenerator) and alias_generator.alias is not None: + parameter_schema['alias'] = alias_generator.alias(name) + elif isinstance(alias_generator, Callable): parameter_schema['alias'] = alias_generator(name) return parameter_schema @@ -1572,7 +1588,11 @@ def _computed_field_schema( # with field_info -> d.info and name -> d.cls_var_name alias_generator = self._config_wrapper.alias_generator if alias_generator and (d.info.alias_priority is None or d.info.alias_priority <= 1): - alias = alias_generator(d.cls_var_name) + alias = None + if isinstance(alias_generator, AliasGenerator) and alias_generator.alias is not None: + alias = alias_generator.alias(d.cls_var_name) + elif isinstance(alias_generator, Callable): + alias = alias_generator(d.cls_var_name) if not isinstance(alias, str): raise TypeError(f'alias_generator {alias_generator} must return str, not {alias.__class__}') d.info.alias = alias diff --git a/pydantic/aliases.py b/pydantic/aliases.py index 0fb22e71e5..c63036dc25 100644 --- a/pydantic/aliases.py +++ b/pydantic/aliases.py @@ -103,8 +103,7 @@ def _generate_alias( TypeError: If the alias generator produces an invalid type. """ alias = None - alias_generator = getattr(self, alias_kind) - if alias_generator is not None: + if alias_generator := getattr(self, alias_kind): alias = alias_generator(field_name) if alias and not isinstance(alias, allowed_types): raise TypeError( @@ -112,8 +111,8 @@ def _generate_alias( ) return alias - def __call__(self, field_name: str) -> tuple[str | AliasPath | AliasChoices | None, str | None, str | None]: - """Generate validation_alias, alias, and serialization_alias for a field via the specified generators. + def generate_aliases(self, field_name: str) -> tuple[str | AliasPath | AliasChoices | None, str | None, str | None]: + """Generate `validation_alias`, `alias`, and `serialization_alias` for a field. Returns: A tuple of three aliases - validation, alias, and serialization. diff --git a/tests/test_aliases.py b/tests/test_aliases.py index 821132fcdf..83fc1a6db3 100644 --- a/tests/test_aliases.py +++ b/tests/test_aliases.py @@ -5,7 +5,16 @@ import pytest from dirty_equals import IsStr -from pydantic import AliasChoices, AliasGenerator, AliasPath, BaseModel, ConfigDict, Field, ValidationError +from pydantic import ( + AliasChoices, + AliasGenerator, + AliasPath, + BaseModel, + ConfigDict, + Field, + ValidationError, + computed_field, +) def test_alias_generator(): @@ -640,3 +649,20 @@ class Model(BaseModel): assert Model.model_fields['a'].validation_alias == 'a_alias' assert Model.model_fields['a'].serialization_alias == 'a_alias' assert Model.model_fields['a'].alias == 'a_alias' + + +@pytest.mark.parametrize('alias_generator', upper_alias_generator) +def test_alias_generator_with_computed_field(alias_generator) -> None: + class Rectangle(BaseModel): + model_config = ConfigDict(populate_by_name=True, alias_generator=alias_generator) + + width: int + height: int + + @computed_field + @property + def area(self) -> int: + return self.width * self.height + + r = Rectangle(width=10, height=20) + assert r.model_dump(by_alias=True) == {'WIDTH': 10, 'HEIGHT': 20, 'AREA': 200} From f4ae9c6ac949213a8e9d262d3b30b3d33d9afd09 Mon Sep 17 00:00:00 2001 From: sydney-runkle Date: Fri, 1 Dec 2023 14:49:44 -0600 Subject: [PATCH 06/12] computed field support --- pydantic/_internal/_generate_schema.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pydantic/_internal/_generate_schema.py b/pydantic/_internal/_generate_schema.py index ab4538def9..33c033a3fe 100644 --- a/pydantic/_internal/_generate_schema.py +++ b/pydantic/_internal/_generate_schema.py @@ -923,7 +923,7 @@ def _generate_dc_field_schema( ) @staticmethod - def _apply_alias_generator( + def _apply_alias_generator_to_field_info( alias_generator: Callable[[str], str] | AliasGenerator, field_info: FieldInfo, field_name: str ) -> None: """Apply an alias_generator to aliases on a FieldInfo instance if appropriate. @@ -1053,7 +1053,7 @@ def set_discriminator(schema: CoreSchema) -> CoreSchema: alias_generator = self._config_wrapper.alias_generator if alias_generator is not None: - self._apply_alias_generator(alias_generator, field_info, name) + self._apply_alias_generator_to_field_info(alias_generator, field_info, name) if isinstance(field_info.validation_alias, (AliasChoices, AliasPath)): validation_alias = field_info.validation_alias.convert_to_aliases() From 833b9083b8099a52b68696fd82bcf548973a5f54 Mon Sep 17 00:00:00 2001 From: sydney-runkle Date: Fri, 1 Dec 2023 14:59:25 -0600 Subject: [PATCH 07/12] reverting lockfile changes, see https://github.com/pydantic/pydantic/pull/8171/files#r1406857320 --- pdm.lock | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/pdm.lock b/pdm.lock index feb9788210..893df317a3 100644 --- a/pdm.lock +++ b/pdm.lock @@ -3,9 +3,8 @@ [metadata] groups = ["default", "docs", "email", "linting", "memray", "mypy", "testing", "testing-extra"] -cross_platform = true -static_urls = false -lock_version = "4.3" +strategy = ["cross_platform"] +lock_version = "4.4" content_hash = "sha256:bfa326e0a46425e1cdaf0d38b8442a10a0a70771a0a7251918eb4b83fecb2298" [[package]] From 5c1994a734c0a430fb15cd2a9085e9c1eee8a666 Mon Sep 17 00:00:00 2001 From: sydney-runkle Date: Fri, 1 Dec 2023 15:04:45 -0600 Subject: [PATCH 08/12] reverting accidental update to lockfile --- pdm.lock | 37 ++++++++++++++++++------------------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/pdm.lock b/pdm.lock index 893df317a3..d37751aac0 100644 --- a/pdm.lock +++ b/pdm.lock @@ -426,15 +426,15 @@ files = [ [[package]] name = "dirty-equals" -version = "0.7.1.post0" +version = "0.6.0" requires_python = ">=3.7" summary = "Doing dirty (but extremely useful) things with equals." dependencies = [ "pytz>=2021.3", ] files = [ - {file = "dirty_equals-0.7.1.post0-py3-none-any.whl", hash = "sha256:7fb9217ea7cd04c0e95ace3bc717e2ee5532b8990518533483e53b5a43903c88"}, - {file = "dirty_equals-0.7.1.post0.tar.gz", hash = "sha256:78ff80578a46163831ecb3255cf30d03d1dc2fbca8e67f820105691a1bc556dc"}, + {file = "dirty_equals-0.6.0-py3-none-any.whl", hash = "sha256:7c29af40193a862ce66f932236c2a4be97489bbf7caf8a90e4a606e7c47c41b3"}, + {file = "dirty_equals-0.6.0.tar.gz", hash = "sha256:4c4e4b9b52670ad8b880c46734e5ffc52e023250ae817398b78b30e329c3955d"}, ] [[package]] @@ -483,16 +483,15 @@ files = [ [[package]] name = "faker" -version = "20.1.0" -requires_python = ">=3.8" +version = "18.13.0" +requires_python = ">=3.7" summary = "Faker is a Python package that generates fake data for you." dependencies = [ "python-dateutil>=2.4", - "typing-extensions>=3.10.0.1; python_version <= \"3.8\"", ] files = [ - {file = "Faker-20.1.0-py3-none-any.whl", hash = "sha256:aeb3e26742863d1e387f9d156f1c36e14af63bf5e6f36fb39b8c27f6a903be38"}, - {file = "Faker-20.1.0.tar.gz", hash = "sha256:562a3a09c3ed3a1a7b20e13d79f904dfdfc5e740f72813ecf95e4cf71e5a2f52"}, + {file = "Faker-18.13.0-py3-none-any.whl", hash = "sha256:801d1a2d71f1fc54d332de2ab19de7452454309937233ea2f7485402882d67b3"}, + {file = "Faker-18.13.0.tar.gz", hash = "sha256:84bcf92bb725dd7341336eea4685df9a364f16f2470c4d29c1d7e6c5fd5a457d"}, ] [[package]] @@ -876,7 +875,7 @@ files = [ [[package]] name = "mkdocs-material" -version = "9.4.14" +version = "9.4.8" requires_python = ">=3.8" summary = "Documentation that simply works" dependencies = [ @@ -893,8 +892,8 @@ dependencies = [ "requests~=2.26", ] files = [ - {file = "mkdocs_material-9.4.14-py3-none-any.whl", hash = "sha256:dbc78a4fea97b74319a6aa9a2f0be575a6028be6958f813ba367188f7b8428f6"}, - {file = "mkdocs_material-9.4.14.tar.gz", hash = "sha256:a511d3ff48fa8718b033e7e37d17abd9cc1de0fdf0244a625ca2ae2387e2416d"}, + {file = "mkdocs_material-9.4.8-py3-none-any.whl", hash = "sha256:8b20f6851bddeef37dced903893cd176cf13a21a482e97705a103c45f06ce9b9"}, + {file = "mkdocs_material-9.4.8.tar.gz", hash = "sha256:f0c101453e8bc12b040e8b64ca39a405d950d8402609b1378cc2b98976e74b5f"}, ] [[package]] @@ -953,7 +952,7 @@ files = [ [[package]] name = "mkdocstrings-python" -version = "1.7.5" +version = "1.7.4" requires_python = ">=3.8" summary = "A Python handler for mkdocstrings." dependencies = [ @@ -961,8 +960,8 @@ dependencies = [ "mkdocstrings>=0.20", ] files = [ - {file = "mkdocstrings_python-1.7.5-py3-none-any.whl", hash = "sha256:5f6246026353f0c0785135db70c3fe9a5d9318990fc7ceb11d62097b8ffdd704"}, - {file = "mkdocstrings_python-1.7.5.tar.gz", hash = "sha256:c7d143728257dbf1aa550446555a554b760dcd40a763f077189d298502b800be"}, + {file = "mkdocstrings_python-1.7.4-py3-none-any.whl", hash = "sha256:70eacbe5f2d5071f2e525ba0b35bc447d398437dfbcd90c63fe6e977551cfe26"}, + {file = "mkdocstrings_python-1.7.4.tar.gz", hash = "sha256:c2fc34efd70000ec31aee247910006e8dd9d1b9f3957bf46880c3f6e51a8f0d5"}, ] [[package]] @@ -1195,16 +1194,16 @@ dependencies = [ [[package]] name = "pydantic-settings" -version = "2.1.0" -requires_python = ">=3.8" +version = "2.0.3" +requires_python = ">=3.7" summary = "Settings management using Pydantic" dependencies = [ - "pydantic>=2.3.0", + "pydantic>=2.0.1", "python-dotenv>=0.21.0", ] files = [ - {file = "pydantic_settings-2.1.0-py3-none-any.whl", hash = "sha256:7621c0cb5d90d1140d2f0ef557bdf03573aac7035948109adf2574770b77605a"}, - {file = "pydantic_settings-2.1.0.tar.gz", hash = "sha256:26b1492e0a24755626ac5e6d715e9077ab7ad4fb5f19a8b7ed7011d52f36141c"}, + {file = "pydantic_settings-2.0.3-py3-none-any.whl", hash = "sha256:ddd907b066622bd67603b75e2ff791875540dc485b7307c4fffc015719da8625"}, + {file = "pydantic_settings-2.0.3.tar.gz", hash = "sha256:962dc3672495aad6ae96a4390fac7e593591e144625e5112d359f8f67fb75945"}, ] [[package]] From 6280f5eb5857702cf7bd665f75583ce26a8da147 Mon Sep 17 00:00:00 2001 From: sydney-runkle Date: Fri, 1 Dec 2023 15:13:30 -0600 Subject: [PATCH 09/12] testing for invalid alias generators --- pydantic/aliases.py | 2 +- tests/test_aliases.py | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/pydantic/aliases.py b/pydantic/aliases.py index c63036dc25..7b71448b2c 100644 --- a/pydantic/aliases.py +++ b/pydantic/aliases.py @@ -107,7 +107,7 @@ def _generate_alias( alias = alias_generator(field_name) if alias and not isinstance(alias, allowed_types): raise TypeError( - f'Invalid `{alias_kind}` type. `{alias_kind} generator` must produce' f' one of `{allowed_types}`' + f'Invalid `{alias_kind}` type. `{alias_kind}` generator must produce one of `{allowed_types}`' ) return alias diff --git a/tests/test_aliases.py b/tests/test_aliases.py index 83fc1a6db3..03cc1a2745 100644 --- a/tests/test_aliases.py +++ b/tests/test_aliases.py @@ -666,3 +666,15 @@ def area(self) -> int: r = Rectangle(width=10, height=20) assert r.model_dump(by_alias=True) == {'WIDTH': 10, 'HEIGHT': 20, 'AREA': 200} + + +def test_alias_generator_with_invalid_callables() -> None: + for alias_kind in ('validation_alias', 'serialization_alias', 'alias'): + with pytest.raises( + TypeError, match=f'Invalid `{alias_kind}` type. `{alias_kind}` generator must produce one of' + ): + + class Foo(BaseModel): + a: str + + model_config = ConfigDict(alias_generator=AliasGenerator(**{alias_kind: lambda x: 1})) From 416c09f3d0c1bbf33bb3e97c8cdf00b2cc05d95c Mon Sep 17 00:00:00 2001 From: sydney-runkle Date: Fri, 1 Dec 2023 15:43:18 -0600 Subject: [PATCH 10/12] a bit more formatting --- pydantic/aliases.py | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/pydantic/aliases.py b/pydantic/aliases.py index 7b71448b2c..ea65394c49 100644 --- a/pydantic/aliases.py +++ b/pydantic/aliases.py @@ -70,27 +70,15 @@ class AliasGenerator: A data class used by `alias_generator` as a convenience to create various aliases. Attributes: - validation_alias: A callable that takes a field name and returns a validation alias for it. alias: A callable that takes a field name and returns an alias for it. + validation_alias: A callable that takes a field name and returns a validation alias for it. serialization_alias: A callable that takes a field name and returns a serialization alias for it. """ - validation_alias: Callable[[str], str | AliasPath | AliasChoices] | None = None alias: Callable[[str], str] | None = None + validation_alias: Callable[[str], str | AliasPath | AliasChoices] | None = None serialization_alias: Callable[[str], str] | None = None - def __init__( - self, - alias: Callable[[str], str] | None = None, - *, - validation_alias: Callable[[str], str | AliasPath | AliasChoices] | None = None, - serialization_alias: Callable[[str], str] | None = None, - ) -> None: - """Initialize the alias generator.""" - self.validation_alias = validation_alias - self.alias = alias - self.serialization_alias = serialization_alias - def _generate_alias( self, alias_kind: Literal['validation_alias', 'serialization_alias', 'alias'], From ba6fb183c6d1cff000fb5de2d68976bbf20272b8 Mon Sep 17 00:00:00 2001 From: sydney-runkle Date: Fri, 1 Dec 2023 15:48:27 -0600 Subject: [PATCH 11/12] get all of the ducks in a row in terms of alias orders --- pydantic/_internal/_generate_schema.py | 4 ++-- pydantic/aliases.py | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/pydantic/_internal/_generate_schema.py b/pydantic/_internal/_generate_schema.py index 33c033a3fe..1ac4c6e84c 100644 --- a/pydantic/_internal/_generate_schema.py +++ b/pydantic/_internal/_generate_schema.py @@ -943,10 +943,10 @@ def _apply_alias_generator_to_field_info( or field_info.validation_alias is None or field_info.serialization_alias is None ): - validation_alias, alias, serialization_alias = None, None, None + alias, validation_alias, serialization_alias = None, None, None if isinstance(alias_generator, AliasGenerator): - validation_alias, alias, serialization_alias = alias_generator.generate_aliases(field_name) + alias, validation_alias, serialization_alias = alias_generator.generate_aliases(field_name) elif isinstance(alias_generator, Callable): alias = alias_generator(field_name) if not isinstance(alias, str): diff --git a/pydantic/aliases.py b/pydantic/aliases.py index ea65394c49..b53557b149 100644 --- a/pydantic/aliases.py +++ b/pydantic/aliases.py @@ -81,7 +81,7 @@ class AliasGenerator: def _generate_alias( self, - alias_kind: Literal['validation_alias', 'serialization_alias', 'alias'], + alias_kind: Literal['alias', 'validation_alias', 'serialization_alias'], allowed_types: tuple[type[str] | type[AliasPath] | type[AliasChoices], ...], field_name: str, ) -> str | AliasPath | AliasChoices | None: @@ -99,14 +99,14 @@ def _generate_alias( ) return alias - def generate_aliases(self, field_name: str) -> tuple[str | AliasPath | AliasChoices | None, str | None, str | None]: - """Generate `validation_alias`, `alias`, and `serialization_alias` for a field. + def generate_aliases(self, field_name: str) -> tuple[str | None, str | AliasPath | AliasChoices | None, str | None]: + """Generate `alias`, `validation_alias`, and `serialization_alias` for a field. Returns: A tuple of three aliases - validation, alias, and serialization. """ - validation_alias = self._generate_alias('validation_alias', (str, AliasChoices, AliasPath), field_name) alias = self._generate_alias('alias', (str,), field_name) + validation_alias = self._generate_alias('validation_alias', (str, AliasChoices, AliasPath), field_name) serialization_alias = self._generate_alias('serialization_alias', (str,), field_name) - return validation_alias, alias, serialization_alias # type: ignore + return alias, validation_alias, serialization_alias # type: ignore From 62dde224a41ae92b960e07f99c09f1a306ff9f4d Mon Sep 17 00:00:00 2001 From: sydney-runkle Date: Fri, 1 Dec 2023 15:54:43 -0600 Subject: [PATCH 12/12] another test --- tests/test_aliases.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/test_aliases.py b/tests/test_aliases.py index 03cc1a2745..fb21655748 100644 --- a/tests/test_aliases.py +++ b/tests/test_aliases.py @@ -678,3 +678,27 @@ class Foo(BaseModel): a: str model_config = ConfigDict(alias_generator=AliasGenerator(**{alias_kind: lambda x: 1})) + + +def test_all_alias_kinds_specified() -> None: + class Foo(BaseModel): + a: str + + model_config = ConfigDict( + alias_generator=AliasGenerator( + alias=lambda field_name: f'{field_name}_alias', + validation_alias=lambda field_name: f'{field_name}_val_alias', + serialization_alias=lambda field_name: f'{field_name}_ser_alias', + ) + ) + + assert Foo.model_fields['a'].alias == 'a_alias' + assert Foo.model_fields['a'].validation_alias == 'a_val_alias' + assert Foo.model_fields['a'].serialization_alias == 'a_ser_alias' + + # the same behavior we'd expect if we defined alias, validation_alias + # and serialization_alias on the field itself + f = Foo(a_val_alias='a') + assert f.a == 'a' + assert f.model_dump(by_alias=True) == {'a_ser_alias': 'a'} + assert f.model_dump(by_alias=False) == {'a': 'a'}