10000 Use the `typing-inspection` library by Viicos · Pull Request #11479 · pydantic/pydantic · GitHub
[go: up one dir, main page]

Skip to content

Use the typing-inspection library #11479

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Feb 28, 2025
Merged

Use the typing-inspection library #11479

merged 10 commits into from
Feb 28, 2025

Conversation

Viicos
Copy link
Member
@Viicos Viicos commented Feb 23, 2025

Change Summary

Needs #11475 to be merged first.

Fixes #9904, fixes #11346, fixes #11510.

Related issue number

Checklist

  • The pull request title is a good summary of the changes - it will be used in the changelog
  • Unit tests for the changes exist
  • Tests pass on CI
  • Documentation reflects the changes where applicable
  • My PR is ready to review, please add a comment including the phrase "please review" to assign reviewers

This commit removes most of the introspection functions defined
in the `_typing_extra` module, and replaces usage with the ones
from `typing-inspection` instead.

Small improvements/changes also have been applied.
@github-actions github-actions bot added the relnotes-fix Used for bugfixes. label Feb 23, 2025
Copy link
cloudflare-workers-and-pages bot commented Feb 23, 2025

Deploying pydantic-docs with  Cloudflare Pages  Cloudflare Pages

Latest commit: 3701115
Status: ✅  Deploy successful!
Preview URL: https://4181d96a.pydantic-docs.pages.dev
Branch Preview URL: https://typing-inspection.pydantic-docs.pages.dev

View logs

Copy link
codspeed-hq bot commented Feb 23, 2025

CodSpeed Performance Report

Merging #11479 will not alter performance

Comparing typing-inspection (3701115) with main (98348d0)

Summary

✅ 46 untouched benchmarks

@Viicos Viicos force-pushed the typing-inspection branch 2 times, most recently from 4170deb to b343465 Compare February 24, 2025 17:18
This error will be used as a wrapper to the `ForbiddenQualifier`
exception from the `typing-inspection` library, with a clear error
message.
This simplifies the logic on our end: we don't need to manually
unwrap the annotated forms and qualifiers. The `inspect_annotation()`
is also more robust, and support more cases (as per the added test).
The typed dict schema logic is left unchanged, as the next commit
will refactor how required keys are detected, and will also add
logic related to `ReadOnly`.
Instead of trying to find `Required`/`NotRequired` qualifiers (which
could be nested under `Annotated` forms and/or `ReadOnly`), we rely
on the `FieldInfo._qualifiers` private attribute, populated from
the `inspect_annotation()` result which is guaranteed to correctly
handle these cases.

We also now don't error if we encounter `ReadOnly`, but raise a
warning stating we don't enforce immutability of the dict item.
The source is provided, and the logic to detect final fields
with a default value is moved *after* the `FieldInfo` instance
is created.

The reason to do so is to again support more edge cases, e.g.
When the annotation was wrapped with `Annotated` (e.g.
`Annotated[Final[int], ...] = 1`. This previously wasn't considered
and as such wasn't treated as a classvar. As per the linked issue
in this PR, this led to an inconsistency, so while technically
this can be considered as a breaking change, we'll consider this as
a bug.
@Viicos Viicos force-pushed the typing-inspection branch 2 times, most recently from 882f11d to ffc1f3b Compare February 26, 2025 09:58
default.metadata += annotation_metadata
default = default.merge_field_infos(
*[x for x in annotation_metadata if isinstance(x, FieldInfo)], default, annotation=default.annotation
# TODO infer from the default
Copy link
Member Author

Choose a reason for hiding this comment

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

This can wait for v3, as if the field has a default it is currently treated as a class var anyway. This todo is thus linked in #11119.

Copy link
Contributor
github-actions bot commented Feb 26, 2025

Coverage report

Click to see where and how coverage changed

FileStatementsMissingCoverageCoverage
(new stmts)
Lines missing
  pydantic
  errors.py
  fields.py
  json_schema.py
  types.py
  pydantic/_internal
  _core_utils.py
  _fields.py
  _generate_schema.py
  _generics.py
  _model_construction.py
  _repr.py
  _typing_extra.py
  _validators.py
Project Total  

This report was generated by python-coverage-comment-action

@Viicos Viicos marked this pull request as ready for review February 26, 2025 15:33
f'The annotation {_repr.display_as_type(annotation)!r} contains the {self._qualifier_repr_map[qualifier]!r} '
f'type qualifier, which is invalid in the context it is defined.'
),
code=None,
Copy link
Member Author

Choose a reason for hiding this comment

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

Might need a new code

Copy link
Contributor

Choose a reason for hiding this comment

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

Might as well 🤷


from . import _repr
from ._typing_extra import is_generic_alias, is_type_alias_type
from ._typing_extra import is_generic_alias
Copy link
Contributor

Choose a reason for hiding this comment

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

Why is is_generic_alias not included in typing_inspection?

Copy link
Member Author
@Viicos Viicos Feb 27, 2025

Choose a reason for hiding this comment

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

is_generic_alias does an isinstance() check against typing._GenericAlias (e.g. List[int] is an instance of such a class), which isn't documented and technically private (although I don't think it will change). So it's best to avoid relying on it.

It is also a footgun as while is_generic_alias() works for all parameterized typing objects, it doesn't check for new unions (is_generic_alias(int | str) == False, but is_generic_alias(Union[int, str]) == True). For instance, I'm not sure we expected new unions to be skipped here:

def get_type_ref(type_: Any, args_override: tuple[type[Any], ...] | None = None) -> str:
"""Produces the ref to be used for this type by pydantic_core's core schemas.
This `args_override` argument was added for the purpose of creating valid recursive references
when creating generic models without needing to create a concrete class.
"""
origin = get_origin(type_) or type_
args = get_args(type_) if is_generic_alias(type_) else (args_override or ())

Similarly, I've used this function here as a way to check for type[list[int]] forms (here type_param is list[int]):

if _typing_extra.is_generic_alias(type_param):
raise PydanticUserError(
'Subscripting `type[]` with an already parametrized type is not supported. '
f'Instead of using type[{type_param!r}], use type[{_repr.display_as_type(get_origin(type_param))}].',
code=None,

This would also match type[Union[int, str]], which we actually want to support! Thankfully there's a specific check for unions just before, but this could easily be missed.


I think there are still valid use cases where you want to check if something is a generic alias (and by that I don't mean isinstance(obj, (types.GenericAlias, typing._GenericAlias), but if the obj is a parameterized generic class -- excluding unions, typing special forms like Literal, Annotated, etc), but it's probably best to rely on get_origin() and the typing_objects check functions.

@@ -286,7 +287,7 @@ def _warn_on_nested_alias_in_annotation(ann_type: type[Any], ann_name: str) -> N
args = getattr(ann_type, '__args__', None)
if args:
for anno_arg in args:
if _typing_extra.is_annotated(anno_arg):
if typing_objects.is_annotated(get_origin(anno_arg)):
Copy link
Contributor

Choose a reason for hiding this comment

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

I presume it's expected that the origin is always fetched before feeding into a typing objects check?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, as per the guide, for performance reasons and also to have a cleaner approach, you should call get_origin() once, and the use the typing_objects.is_smth(origin) functions to analyze the object.

In Pydantic, our is_smth() functions were already calling get_origin(), but then we can end up with patterns like:

if is_annotated(obj):  # one get_origin() call under the hood
    ...
elif is_literal(obj):  # two get_origin() calls now
    ...

This was convenient as shown on this if _typing_extra.is_annotated() call, but can decrease performance overall.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yes, definitely good to minimize get_origin calls.

@@ -1104,9 +1107,9 @@ def _match_generic_type(self, obj: Any, origin: Any) -> CoreSchema: # noqa: C90
if schema is not None:
return schema

if _typing_extra.is_type_alias_type(origin):
if typing_objects.is_typealiastype(origin):
Copy link
Contributor

Choose a reason for hiding this comment

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

Would is_type_alias_type make more sense?

Copy link
Member Author

Choose a reason for hiding this comment

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

I wanted to avoid having the name depending on the capitalization of the object, and simply have is_<name>, but both make sense I think

Copy link
Contributor

Choose a reason for hiding this comment

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

I'd prefer is_type_alias_type just bc it's easier to read, but it's up to you, doesn't really matter!

@@ -297,15 +297,15 @@ def replace_types(type_: Any, type_map: Mapping[TypeVar, Any] | None) -> Any:
origin_type = getattr(typing, type_._name)
assert origin_type is not None

if _typing_extra.origin_is_union(origin_type):
if any(_typing_extra.is_any(arg) for arg in resolved_type_args):
if is_union_origin(origin_type):
Copy link
Contributor

Choose a reason for hiding this comment

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

Why not just is_union?

Copy link
Member Author

Choose a reason for hiding this comment

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

To not confuse it with typing_object.is_union(), which only checks for typing.Union (as it does for all the other functions in typing_objects), I've tried finding a better name but couldn't find any

Copy link
Contributor

Choose a reason for hiding this comment

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

Discussed at standup today, could be changed for clarity on the typing-inspection end, but I don't think this PR needs to be held up because of it.

core_schema_types: list[CoreSchemaOrFieldType] = _typing_extra.literal_values(
CoreSchemaOrFieldType # type: ignore
)
core_schema_types: list[CoreSchemaOrFieldType] = list(get_literal_values(CoreSchemaOrFieldType))
Copy link
Contributor

Choose a reason for hiding this comment

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

Why are we yielding from get_literal_values? Seems like we always wrap in a list?

Copy link
Member Author

Choose a reason for hiding this comment

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

We (Pydantic) always wrap in a list, but others might not:

for elem in get_literal_values(Literal[1, 2]):
    ...

Copy link
Contributor

Choose a reason for hiding this comment

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

Doesn't strike me as necessary, but also, very low stakes, feel free to leave it as you wish

Copy link
Contributor
@sydney-runkle sydney-runkle left a comment

Choose a reason for hiding this comment

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

Looking good overall - I've left some nit picky questions + sent you some general feedback about typing-inspection :)

Added comments, removed leftover function
Copy link
Contributor
@sydney-runkle sydney-runkle left a comment

Choose a reason for hiding this comment

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

Exciting to see a lot of these common concepts pulled out into typing-inspection. Even more exciting to see simplifications here.

pydantic-settings relies on it.
@Viicos Viicos added the third-party-tests Add this label on a PR to trigger 3rd party tests label Feb 28, 2025
@Viicos Viicos closed this Feb 28, 2025
@Viicos Viicos reopened this Feb 28, 2025
The library is incorrectly hooking into generic aliases.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
relnotes-fix Used for bugfixes. third-party-tests Add this label on a PR to trigger 3rd party tests
Projects
None yet
2 participants
0