Skip to content

Conversation

tselepakis
Copy link
Contributor

@tselepakis tselepakis commented Jun 26, 2025

This PR ensures that type aliases declared using type (PEP 695) are correctly resolved and handled during environment value parsing and model field introspection.

I aimed to keep the changes minimal, but addressing compatibility issues required introducing a couple of new patterns, specifically to work around syntax errors triggered by pytest when using Python versions <3.12, as well as ruff linter errors. If there are better or more idiomatic approaches, I'm happy to discuss them. I'm not certain the current solutions are the best long-term fit for the project.

Fixes #536

@tselepakis tselepakis force-pushed the issue-536 branch 2 times, most recently from 6a5e7ad to 5f9a0bf Compare June 26, 2025 21:04
@hramezani hramezani requested a review from Viicos June 26, 2025 21:28
@Viicos
Copy link
Member

Viicos commented Jun 27, 2025

Thanks @tselepakis, I would suggest using the following patch instead:

diff --git a/pydantic_settings/sources/base.py b/pydantic_settings/sources/base.py
index b2c4d16..881546b 100644
--- a/pydantic_settings/sources/base.py
+++ b/pydantic_settings/sources/base.py
@@ -16,6 +16,7 @@ from pydantic._internal._typing_extra import (  # type: ignore[attr-defined]
 from pydantic._internal._utils import is_model_class
 from pydantic.fields import FieldInfo
 from typing_extensions import get_args
+from typing_inspection import typing_objects
 from typing_inspection.introspection import is_union_origin
 
 from ..exceptions import SettingsError
@@ -353,6 +354,12 @@ class PydanticBaseEnvSettingsSource(PydanticBaseSettingsSource):
                 field_info.append((v_alias, self._apply_case_sensitive(v_alias), False))
 
         if not v_alias or self.config.get('populate_by_name', False):
+            if typing_objects.is_typealiastype(field.annotation) or typing_objects.is_typealiastype(get_origin(field.annotation)):
+                annotation = field.annotation.__value__
+            else:
+                annotation = field.annotation
+            if typing_objects.is_annotated(get_origin(annotation)):
+                annotation = annotation.__origin__
             if is_union_origin(get_origin(field.annotation)) and _union_is_complex(field.annotation, field.metadata):
                 field_info.append((field_name, self._apply_case_sensitive(self.env_prefix + field_name), True))
             else:
diff --git a/pydantic_settings/sources/utils.py b/pydantic_settings/sources/utils.py
index 270a8c1..498a8dc 100644
--- a/pydantic_settings/sources/utils.py
+++ b/pydantic_settings/sources/utils.py
@@ -43,6 +43,9 @@ def parse_env_vars(
 def _annotation_is_complex(annotation: type[Any] | None, metadata: list[Any]) -> bool:
     # If the model is a root model, the root annotation should be used to
     # evaluate the complexity.
+    if typing_objects.is_typealiastype(annotation) or typing_objects.is_typealiastype(get_origin(annotation)):
+        annotation = annotation.__value__
+
     if annotation is not None and _lenient_issubclass(annotation, RootModel) and annotation is not RootModel:
         annotation = cast('type[RootModel[Any]]', annotation)
         root_annotation = annotation.model_fields['root'].annotation

That's really hacky and looking at the code related to this I'm pretty sure it contains many other bugs, but we can't do better considering the current state of pydantic-settings.

@tselepakis
Copy link
Contributor Author

Thanks for the review and the detailed patch, @Viicos. I appreciate you taking the time to provide a solution.

I have a question regarding the first change in base.py. You've introduced a new annotation variable that strips away TypeAlias and Annotated wrappers.

However, the is_union_origin check immediately after that, still uses get_origin(field.annotation) instead of get_origin(annotation). Was this intentional, or should the is_union_origin check also use the unwrapped annotation variable?

Additionally, could you please explain the specific bug or use case that this logic is intended to cover (if I remove this part my test still passes, perhaps we need another test to cover this case)? I'm trying to understand which scenario requires us to check for is_typealiastype and is_annotated here. As you mentioned that the solution is "hacky", understanding the exact case would be very helpful for me.

@Viicos
Copy link
Member

Viicos commented Jun 30, 2025

However, the is_union_origin check immediately after that, still uses get_origin(field.annotation) instead of get_origin(annotation). Was this intentional, or should the is_union_origin check also use the unwrapped annotation variable?

That's an oversight sorry, it should check annotation, not field.annotation.

Additionally, could you please explain the specific bug or use case that this logic is intended to cover (if I remove this part my test still passes, perhaps we need another test to cover this case)? I'm trying to understand which scenario requires us to check for is_typealiastype and is_annotated here. As you mentioned that the solution is "hacky", understanding the exact case would be very helpful for me.

I believe both options would have worked but yours was more likely to cause unexpected issues as you implemented the type alias "unpacking" in get_origin()/get_args(), which in theory can be used anywhere in the code base. My proposed change is only scoped to the places where it is necessary to unpack them.

@Viicos Viicos changed the title Fix: Handle type alias declared with type statement Inspect type aliases to determine if an annotation is complex Jun 30, 2025
Copy link
Member

@Viicos Viicos left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @tselepakis!

@Viicos Viicos merged commit 50dedf7 into pydantic:main Jul 6, 2025
19 checks passed
@Fabbi
Copy link

Fabbi commented Sep 29, 2025

this breaks for type aliases with BeforeValidator like

from sqlalchemy.engine.url import URL, make_url

type URLStr = Annotated[URL, BeforeValidator(make_url)]

class Settings(BaseSettings):
    DATABASE_URL: URLStr

adding NoDecode to the typeAlias doesn't work either, because fields.py:from_annotation skips unpacking the type alias, thus not picking up the NoDecode.

What works is

from sqlalchemy.engine.url import URL, make_url

type URLStr = Annotated[URL, BeforeValidator(make_url)]

class Settings(BaseSettings):
    DATABASE_URL: Annotated[URLStr, NoDecode]

which kind of defeats the purpose of the alias

@tselepakis
Copy link
Contributor Author

tselepakis commented Sep 29, 2025

Nice catch @Fabbi . I think the problem has to do with the missing metadata here:
https://github.com/pydantic/pydantic-settings/blob/main/pydantic_settings/sources/base.py#L183

@Viicos shall we introduce a new util function to also support annotated fields there? Something like this maybe?

def _get_field_metadata(field: Any) -> list[Any]:
    annotation = field.annotation
    metadata = field.metadata
    if typing_objects.is_typealiastype(annotation) or typing_objects.is_typealiastype(get_origin(annotation)):
        annotation = annotation.__value__
    origin = get_origin(annotation)
    if typing_objects.is_annotated(origin):
        _, *meta = get_args(annotation)
        metadata += meta
    return metadata

If you agree with the approach I can prepare an MR.
main...tselepakis:pydantic-settings:issue-536-fix

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Pydantic Settings cannot deserialize JSON value of type declared with PEP-695 style type alias

3 participants