Skip to content

Commit acf8c14

Browse files
hramezaniclaude
andauthored
Fix JSON decoding for parameterized PEP 695 type aliases (#780)
Co-authored-by: Claude Opus 4.6 <[email protected]>
1 parent 58b236a commit acf8c14

3 files changed

Lines changed: 83 additions & 10 deletions

File tree

pydantic_settings/sources/base.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
)
1616
from pydantic._internal._utils import deep_update, is_model_class
1717
from pydantic.fields import FieldInfo
18-
from typing_inspection import typing_objects
1918
from typing_inspection.introspection import is_union_origin
2019

2120
from ..exceptions import SettingsError
@@ -26,6 +25,7 @@
2625
_get_alias_names,
2726
_get_field_metadata,
2827
_get_model_fields,
28+
_resolve_type_alias,
2929
_strip_annotated,
3030
_union_is_complex,
3131
)
@@ -402,10 +402,8 @@ def _extract_field_info(self, field: FieldInfo, field_name: str) -> list[tuple[s
402402
field_info.append((v_alias, self._apply_case_sensitive(env_prefix + v_alias), False))
403403

404404
if not v_alias or self.config.get('populate_by_name', False) or self.config.get('validate_by_name', False):
405-
annotation = field.annotation
405+
annotation = _strip_annotated(_resolve_type_alias(field.annotation))
406406
env_prefix = self.env_prefix if self.env_prefix_target in ('variable', 'all') else ''
407-
if typing_objects.is_typealiastype(annotation) or typing_objects.is_typealiastype(get_origin(annotation)):
408-
annotation = _strip_annotated(annotation.__value__) # type: ignore[union-attr]
409407
if is_union_origin(get_origin(annotation)) and _union_is_complex(annotation, field.metadata):
410408
field_info.append((field_name, self._apply_case_sensitive(env_prefix + field_name), True))
411409
else:

pydantic_settings/sources/utils.py

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from collections.abc import Mapping, Sequence
77
from dataclasses import is_dataclass
88
from enum import Enum
9-
from typing import Any, cast, get_args, get_origin
9+
from typing import Any, TypeVar, cast, get_args, get_origin
1010

1111
from pydantic import BaseModel, Json, RootModel, Secret
1212
from pydantic._internal._utils import is_model_class
@@ -40,11 +40,49 @@ def parse_env_vars(
4040
}
4141

4242

43+
def _substitute_typevars(tp: Any, param_map: dict[Any, Any]) -> Any:
44+
"""Substitute TypeVars in a type annotation with concrete types from param_map."""
45+
if isinstance(tp, TypeVar) and tp in param_map:
46+
return param_map[tp]
47+
args = get_args(tp)
48+
if not args:
49+
return tp
50+
new_args = tuple(_substitute_typevars(arg, param_map) for arg in args)
51+
if new_args == args:
52+
return tp
53+
origin = get_origin(tp)
54+
if origin is not None:
55+
try:
56+
return origin[new_args]
57+
except TypeError:
58+
# types.UnionType and similar are not directly subscriptable,
59+
# reconstruct using | operator
60+
import functools
61+
import operator
62+
63+
return functools.reduce(operator.or_, new_args)
64+
return tp
65+
66+
67+
def _resolve_type_alias(annotation: Any) -> Any:
68+
"""Resolve a TypeAliasType to its underlying value, substituting type params if parameterized."""
69+
if typing_objects.is_typealiastype(annotation):
70+
return annotation.__value__
71+
origin = get_origin(annotation)
72+
if typing_objects.is_typealiastype(origin):
73+
type_params = getattr(origin, '__type_params__', ())
74+
type_args = get_args(annotation)
75+
value = origin.__value__
76+
if type_params and type_args:
77+
return _substitute_typevars(value, dict(zip(type_params, type_args)))
78+
return value
79+
return annotation
80+
81+
4382
def _annotation_is_complex(annotation: Any, metadata: list[Any]) -> bool:
4483
# If the model is a root model, the root annotation should be used to
4584
# evaluate the complexity.
46-
if typing_objects.is_typealiastype(annotation) or typing_objects.is_typealiastype(get_origin(annotation)):
47-
annotation = annotation.__value__
85+
annotation = _resolve_type_alias(annotation)
4886
if annotation is not None and _lenient_issubclass(annotation, RootModel) and annotation is not RootModel:
4987
annotation = cast('type[RootModel[Any]]', annotation)
5088
root_annotation = annotation.model_fields['root'].annotation
@@ -74,10 +112,8 @@ def _annotation_is_complex(annotation: Any, metadata: list[Any]) -> bool:
74112

75113

76114
def _get_field_metadata(field: FieldInfo) -> list[Any]:
77-
annotation = field.annotation
115+
annotation = _resolve_type_alias(field.annotation)
78116
metadata = field.metadata
79-
if typing_objects.is_typealiastype(annotation) or typing_objects.is_typealiastype(get_origin(annotation)):
80-
annotation = annotation.__value__ # type: ignore[union-attr]
81117
origin = get_origin(annotation)
82118
if typing_objects.is_annotated(origin):
83119
_, *meta = get_args(annotation)
@@ -240,6 +276,7 @@ def _is_function(obj: Any) -> bool:
240276
'_get_model_fields',
241277
'_is_function',
242278
'_parse_env_none_str',
279+
'_resolve_type_alias',
243280
'_strip_annotated',
244281
'_union_is_complex',
245282
'parse_env_vars',

tests/test_settings.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -499,6 +499,44 @@ class AnnotatedComplexSettings(BaseSettings):
499499
assert s.apples == ['russet', 'granny smith']
500500

501501

502+
def test_annotated_with_parameterized_type_alias(env):
503+
"""https://github.com/pydantic/pydantic-settings/issues/778.
504+
505+
Parameterized PEP 695 type aliases should correctly substitute type params
506+
when determining if a field is complex.
507+
"""
508+
from annotated_types import Len
509+
510+
T = TypeVar('T')
511+
512+
MaxLenA = Annotated[T, Len(max_length=4)]
513+
MaxLenB = TypeAliasType('MaxLenB', Annotated[T, Len(max_length=4)], type_params=(T,))
514+
MaxLenC = TypeAliasType('MaxLenC', Annotated[T, Len(max_length=4), ForceDecode], type_params=(T,))
515+
516+
class MySettingsA(BaseSettings):
517+
SIMPLE_LIST: MaxLenA[list[str]]
518+
519+
class MySettingsB(BaseSettings):
520+
SIMPLE_LIST: MaxLenB[list[str]]
521+
522+
class MySettingsC(BaseSettings):
523+
SIMPLE_LIST: MaxLenC[list[str]]
524+
525+
env.set('SIMPLE_LIST', '["a", "b", "c"]')
526+
assert MySettingsA().SIMPLE_LIST == ['a', 'b', 'c']
527+
assert MySettingsB().SIMPLE_LIST == ['a', 'b', 'c']
528+
assert MySettingsC().SIMPLE_LIST == ['a', 'b', 'c']
529+
530+
# NoDecode should prevent automatic JSON decoding
531+
MaxLenD = TypeAliasType('MaxLenD', Annotated[T, Len(max_length=4), NoDecode], type_params=(T,))
532+
533+
class MySettingsD(BaseSettings):
534+
SIMPLE_LIST: MaxLenD[list[str]]
535+
536+
with pytest.raises(ValidationError):
537+
MySettingsD()
538+
539+
502540
def test_annotated_with_type_no_decode(env):
503541
A = TypeAliasType('A', Annotated[list[str], NoDecode])
504542

0 commit comments

Comments
 (0)