Skip to content

Commit 1a329d4

Browse files
committed
Merge branch 'main' into deep-merge
2 parents 4c6fcbd + fc8a694 commit 1a329d4

File tree

21 files changed

+1230
-1178
lines changed

21 files changed

+1230
-1178
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ jobs:
3636
fail-fast: false
3737
matrix:
3838
os: [ubuntu-latest, macos-latest, windows-latest]
39-
python: ['3.9', '3.10', '3.11', '3.12', '3.13']
39+
python: ['3.10', '3.11', '3.12', '3.13', '3.14']
4040

4141
env:
4242
PYTHON: ${{ matrix.python }}

pydantic_settings/main.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
YamlConfigSettingsSource,
3636
get_subcommand,
3737
)
38+
from .sources.utils import _get_alias_names
3839

3940
T = TypeVar('T')
4041

@@ -444,13 +445,37 @@ def _settings_build_values(
444445

445446
# Strip any default values not explicity set before returning final state
446447
state = {key: val for key, val in state.items() if key not in defaults or defaults[key] != val}
448+
self._settings_restore_init_kwarg_names(self.__class__, init_kwargs, state)
447449

448450
return state
449451
else:
450452
# no one should mean to do this, but I think returning an empty dict is marginally preferable
451453
# to an informative error and much better than a confusing error
452454
return {}
453455

456+
@staticmethod
457+
def _settings_restore_init_kwarg_names(
458+
settings_cls: type[BaseSettings], init_kwargs: dict[str, Any], state: dict[str, Any]
459+
) -> None:
460+
"""
461+
Restore the init_kwarg key names to the final merged state dictionary.
462+
"""
463+
if init_kwargs and state:
464+
state_kwarg_names = set(state.keys())
465+
init_kwarg_names = set(init_kwargs.keys())
466+
for field_name, field_info in settings_cls.model_fields.items():
467+
alias_names, *_ = _get_alias_names(field_name, field_info)
468+
matchable_names = set(alias_names)
469+
include_name = settings_cls.model_config.get(
470+
'populate_by_name', False
471+
) or settings_cls.model_config.get('validate_by_name', False)
472+
if include_name:
473+
matchable_names.add(field_name)
474+
init_kwarg_name = init_kwarg_names & matchable_names
475+
state_kwarg_name = state_kwarg_names & matchable_names
476+
if init_kwarg_name and state_kwarg_name:
477+
state[init_kwarg_name.pop()] = state.pop(state_kwarg_name.pop())
478+
454479
@staticmethod
455480
def _settings_warn_unused_config_keys(sources: tuple[object, ...], model_config: SettingsConfigDict) -> None:
456481
"""

pydantic_settings/sources/base.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,14 @@
77
from abc import ABC, abstractmethod
88
from dataclasses import asdict, is_dataclass
99
from pathlib import Path
10-
from typing import TYPE_CHECKING, Any, Optional, cast
10+
from typing import TYPE_CHECKING, Any, cast, get_args
1111

1212
from pydantic import AliasChoices, AliasPath, BaseModel, TypeAdapter
1313
from pydantic._internal._typing_extra import ( # type: ignore[attr-defined]
1414
get_origin,
1515
)
1616
from pydantic._internal._utils import deep_update, is_model_class
1717
from pydantic.fields import FieldInfo
18-
from typing_extensions import get_args
1918
from typing_inspection import typing_objects
2019
from typing_inspection.introspection import is_union_origin
2120

@@ -36,7 +35,7 @@
3635

3736
def get_subcommand(
3837
model: PydanticModel, is_required: bool = True, cli_exit_on_error: bool | None = None
39-
) -> Optional[PydanticModel]:
38+
) -> PydanticModel | None:
4039
"""
4140
Get the subcommand from a model.
4241
@@ -269,7 +268,9 @@ def __init__(
269268
# When populate_by_name is True, allow using the field name as an input key,
270269
# but normalize to the preferred alias to keep keys consistent across sources.
271270
matchable_names = set(alias_names)
272-
include_name = settings_cls.model_config.get('populate_by_name', False)
271+
include_name = settings_cls.model_config.get('populate_by_name', False) or settings_cls.model_config.get(
272+
'validate_by_name', False
273+
)
273274
if include_name:
274275
matchable_names.add(field_name)
275276
init_kwarg_name = init_kwarg_names & matchable_names
@@ -371,7 +372,7 @@ def _extract_field_info(self, field: FieldInfo, field_name: str) -> list[tuple[s
371372
else: # string validation alias
372373
field_info.append((v_alias, self._apply_case_sensitive(v_alias), False))
373374

374-
if not v_alias or self.config.get('populate_by_name', False):
375+
if not v_alias or self.config.get('populate_by_name', False) or self.config.get('validate_by_name', False):
375376
annotation = field.annotation
376377
if typing_objects.is_typealiastype(annotation) or typing_objects.is_typealiastype(get_origin(annotation)):
377378
annotation = _strip_annotated(annotation.__value__) # type: ignore[union-attr]
@@ -491,7 +492,13 @@ def _get_resolved_field_value(self, field: FieldInfo, field_name: str) -> tuple[
491492
A tuple that contains the value, preferred key and a flag to determine whether value is complex.
492493
"""
493494
field_value, field_key, value_is_complex = self.get_field_value(field, field_name)
494-
if not (value_is_complex or (self.config.get('populate_by_name', False) and (field_key == field_name))):
495+
if not (
496+
value_is_complex
497+
or (
498+
(self.config.get('populate_by_name', False) or self.config.get('validate_by_name', False))
499+
and (field_key == field_name)
500+
)
501+
):
495502
field_infos = self._extract_field_info(field, field_name)
496503
preferred_key, *_ = field_infos[0]
497504
return field_value, preferred_key, value_is_complex

pydantic_settings/sources/providers/aws.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import json
44
from collections.abc import Mapping
5-
from typing import TYPE_CHECKING, Optional
5+
from typing import TYPE_CHECKING
66

77
from ..utils import parse_env_vars
88
from .env import EnvSettingsSource
@@ -57,7 +57,7 @@ def __init__(
5757
env_parse_enums=env_parse_enums,
5858
)
5959

60-
def _load_env_vars(self) -> Mapping[str, Optional[str]]:
60+
def _load_env_vars(self) -> Mapping[str, str | None]:
6161
response = self._secretsmanager_client.get_secret_value(SecretId=self._secret_id) # type: ignore
6262

6363
return parse_env_vars(

pydantic_settings/sources/providers/azure.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from __future__ import annotations as _annotations
44

55
from collections.abc import Iterator, Mapping
6-
from typing import TYPE_CHECKING, Optional
6+
from typing import TYPE_CHECKING
77

88
from pydantic.alias_generators import to_snake
99
from pydantic.fields import FieldInfo
@@ -37,7 +37,7 @@ def import_azure_key_vault() -> None:
3737
) from e
3838

3939

40-
class AzureKeyVaultMapping(Mapping[str, Optional[str]]):
40+
class AzureKeyVaultMapping(Mapping[str, str | None]):
4141
_loaded_secrets: dict[str, str | None]
4242
_secret_client: SecretClient
4343
_secret_names: list[str]
@@ -121,7 +121,7 @@ def __init__(
121121
env_parse_enums=env_parse_enums,
122122
)
123123

124-
def _load_env_vars(self) -> Mapping[str, Optional[str]]:
124+
def _load_env_vars(self) -> Mapping[str, str | None]:
125125
secret_client = SecretClient(vault_url=self._url, credential=self._credential)
126126
return AzureKeyVaultMapping(
127127
secret_client=secret_client,

pydantic_settings/sources/providers/cli.py

Lines changed: 17 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
_SubParsersAction,
1717
)
1818
from collections import defaultdict
19-
from collections.abc import Mapping, Sequence
19+
from collections.abc import Callable, Mapping, Sequence
2020
from enum import Enum
2121
from functools import cached_property
2222
from textwrap import dedent
@@ -25,14 +25,13 @@
2525
TYPE_CHECKING,
2626
Annotated,
2727
Any,
28-
Callable,
2928
Generic,
3029
Literal,
3130
NoReturn,
32-
Optional,
3331
TypeVar,
34-
Union,
3532
cast,
33+
get_args,
34+
get_origin,
3635
overload,
3736
)
3837

@@ -43,7 +42,6 @@
4342
from pydantic.dataclasses import is_pydantic_dataclass
4443
from pydantic.fields import FieldInfo
4544
from pydantic_core import PydanticUndefined
46-
from typing_extensions import get_args, get_origin
4745
from typing_inspection import typing_objects
4846
from typing_inspection.introspection import is_union_origin
4947

@@ -95,21 +93,21 @@ class _CliArg(BaseModel):
9593
arg_prefix: str
9694
case_sensitive: bool
9795
hide_none_type: bool
98-
kebab_case: Optional[Union[bool, Literal['all', 'no_enums']]]
99-
enable_decoding: Optional[bool]
96+
kebab_case: bool | Literal['all', 'no_enums'] | None
97+
enable_decoding: bool | None
10098
env_prefix_len: int
10199
args: list[str] = []
102100
kwargs: dict[str, Any] = {}
103101

104102
_alias_names: tuple[str, ...] = PrivateAttr(())
105-
_alias_paths: dict[str, Optional[int]] = PrivateAttr({})
103+
_alias_paths: dict[str, int | None] = PrivateAttr({})
106104
_is_alias_path_only: bool = PrivateAttr(False)
107105
_field_info: FieldInfo = PrivateAttr()
108106

109107
def __init__(
110108
self,
111109
field_info: FieldInfo,
112-
parser_map: defaultdict[str | FieldInfo, dict[Optional[int] | str, _CliArg]],
110+
parser_map: defaultdict[str | FieldInfo, dict[int | None | str, _CliArg]],
113111
**values: Any,
114112
) -> None:
115113
super().__init__(**values)
@@ -132,12 +130,12 @@ def __init__(
132130
parser_map[self.field_info][index] = parser_map[alias_path_dest][index]
133131

134132
@classmethod
135-
def get_kebab_case(cls, name: str, kebab_case: Optional[Union[bool, Literal['all', 'no_enums']]]) -> str:
133+
def get_kebab_case(cls, name: str, kebab_case: bool | Literal['all', 'no_enums'] | None) -> str:
136134
return name.replace('_', '-') if kebab_case not in (None, False) else name
137135

138136
@classmethod
139137
def get_enum_names(
140-
cls, annotation: type[Any], kebab_case: Optional[Union[bool, Literal['all', 'no_enums']]]
138+
cls, annotation: type[Any], kebab_case: bool | Literal['all', 'no_enums'] | None
141139
) -> tuple[str, ...]:
142140
enum_names: tuple[str, ...] = ()
143141
annotation = _strip_annotated(annotation)
@@ -157,7 +155,7 @@ def field_info(self) -> FieldInfo:
157155
return self._field_info
158156

159157
@cached_property
160-
def subcommand_dest(self) -> Optional[str]:
158+
def subcommand_dest(self) -> str | None:
161159
return f'{self.arg_prefix}:subcommand' if _CliSubCommand in self.field_info.metadata else None
162160

163161
@cached_property
@@ -206,7 +204,7 @@ def alias_names(self) -> tuple[str, ...]:
206204
return self._alias_names
207205

208206
@cached_property
209-
def alias_paths(self) -> dict[str, Optional[int]]:
207+
def alias_paths(self) -> dict[str, int | None]:
210208
return self._alias_paths
211209

212210
@cached_property
@@ -236,7 +234,7 @@ def is_no_decode(self) -> bool:
236234

237235

238236
T = TypeVar('T')
239-
CliSubCommand = Annotated[Union[T, None], _CliSubCommand]
237+
CliSubCommand = Annotated[T | None, _CliSubCommand]
240238
CliPositionalArg = Annotated[T, _CliPositionalArg]
241239
_CliBoolFlag = TypeVar('_CliBoolFlag', bound=bool)
242240
CliImplicitFlag = Annotated[_CliBoolFlag, _CliImplicitFlag]
@@ -585,9 +583,7 @@ def _is_nested_alias_path_only_workaround(
585583
return True
586584
return False
587585

588-
def _get_merge_parsed_list_types(
589-
self, parsed_list: list[str], field_name: str
590-
) -> tuple[Optional[type], Optional[type]]:
586+
def _get_merge_parsed_list_types(self, parsed_list: list[str], field_name: str) -> tuple[type | None, type | None]:
591587
merge_type = self._cli_dict_args.get(field_name, list)
592588
if (
593589
merge_type is list
@@ -606,7 +602,7 @@ def _get_merge_parsed_list_types(
606602

607603
def _merged_list_to_str(self, merged_list: list[str], field_name: str) -> str:
608604
decode_list: list[str] = []
609-
is_use_decode: Optional[bool] = None
605+
is_use_decode: bool | None = None
610606
cli_arg_map = self._parser_map.get(field_name, {})
611607
for index, item in enumerate(merged_list):
612608
cli_arg = cli_arg_map.get(index)
@@ -867,7 +863,7 @@ def _parse_known_args(*args: Any, **kwargs: Any) -> Namespace:
867863
self._add_subparsers = self._connect_parser_method(add_subparsers_method, 'add_subparsers_method')
868864
self._formatter_class = formatter_class
869865
self._cli_dict_args: dict[str, type[Any] | None] = {}
870-
self._parser_map: defaultdict[str | FieldInfo, dict[Optional[int] | str, _CliArg]] = defaultdict(dict)
866+
self._parser_map: defaultdict[str | FieldInfo, dict[int | None | str, _CliArg]] = defaultdict(dict)
871867
self._add_parser_args(
872868
parser=self.root_parser,
873869
model=self.settings_cls,
@@ -892,7 +888,7 @@ def _add_parser_args(
892888
is_model_suppressed: bool = False,
893889
) -> ArgumentParser:
894890
subparsers: Any = None
895-
alias_path_args: dict[str, Optional[int]] = {}
891+
alias_path_args: dict[str, int | None] = {}
896892
# Ignore model default if the default is a model and not a subclass of the current model.
897893
model_default = (
898894
None
@@ -1159,7 +1155,7 @@ def _add_parser_submodels(
11591155
def _add_parser_alias_paths(
11601156
self,
11611157
parser: Any,
1162-
alias_path_args: dict[str, Optional[int]],
1158+
alias_path_args: dict[str, int | None],
11631159
added_args: list[str],
11641160
arg_prefix: str,
11651161
subcommand_prefix: str,

pydantic_settings/sources/providers/env.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,14 @@
55
from typing import (
66
TYPE_CHECKING,
77
Any,
8+
get_args,
9+
get_origin,
810
)
911

1012
from pydantic import Json, TypeAdapter, ValidationError
1113
from pydantic._internal._utils import deep_update, is_model_class
1214
from pydantic.dataclasses import is_pydantic_dataclass
1315
from pydantic.fields import FieldInfo
14-
from typing_extensions import get_args, get_origin
1516
from typing_inspection.introspection import is_union_origin
1617

1718
from ...utils import _lenient_issubclass

pydantic_settings/sources/providers/gcp.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from collections.abc import Iterator, Mapping
44
from functools import cached_property
5-
from typing import TYPE_CHECKING, Optional
5+
from typing import TYPE_CHECKING
66

77
from .env import EnvSettingsSource
88

@@ -33,7 +33,7 @@ def import_gcp_secret_manager() -> None:
3333
) from e
3434

3535

36-
class GoogleSecretManagerMapping(Mapping[str, Optional[str]]):
36+
class GoogleSecretManagerMapping(Mapping[str, str | None]):
3737
_loaded_secrets: dict[str, str | None]
3838
_secret_client: SecretManagerServiceClient
3939

@@ -140,7 +140,7 @@ def __init__(
140140
env_parse_enums=env_parse_enums,
141141
)
142142

143-
def _load_env_vars(self) -> Mapping[str, Optional[str]]:
143+
def _load_env_vars(self) -> Mapping[str, str | None]:
144144
return GoogleSecretManagerMapping(
145145
self._secret_client, project_id=self._project_id, case_sensitive=self.case_sensitive
146146
)

0 commit comments

Comments
 (0)