Skip to content

[ty] Support recursive and stringified annotations in functional typing.NamedTuples#22718

Merged
AlexWaygood merged 9 commits intomainfrom
alex/defer-namedtuple
Jan 21, 2026
Merged

[ty] Support recursive and stringified annotations in functional typing.NamedTuples#22718
AlexWaygood merged 9 commits intomainfrom
alex/defer-namedtuple

Conversation

@AlexWaygood
Copy link
Member

@AlexWaygood AlexWaygood commented Jan 19, 2026

Fixes astral-sh/ty#2528, fixes astral-sh/ty#2529.

In order to support typing.NamedTuples with recursive or stringified types in their field specs, we must defer inference of the second argument to typing.NamedTuple() calls. However, we don't need to defer inference of collections.namedtuple() calls (those calls don't expect type expressions -- they just expect a string literal or a sequence of string literals), and nor do we need to defer inference of "dangling" NamedTuple calls. Dangling NamedTuple classes can be recursive, but only in the context of when they appear inside a class's bases list, and we already defer inference of all expressions inside a class's bases list.

Test plan

Mdtests updated and extended

Co-authored-by @charliermarsh (picking up from #22627)

@AlexWaygood AlexWaygood added ty Multi-file analysis & type inference ecosystem-analyzer labels Jan 19, 2026
@astral-sh-bot
Copy link

astral-sh-bot bot commented Jan 19, 2026

Typing conformance results

No changes detected ✅

@astral-sh-bot
Copy link

astral-sh-bot bot commented Jan 19, 2026

mypy_primer results

Changes were detected when running on open source projects
prefect (https://github.com/PrefectHQ/prefect)
- src/integrations/prefect-dbt/prefect_dbt/core/settings.py:94:28: error[invalid-assignment] Object of type `dict[str, Any] | int | T@resolve_block_document_references | ... omitted 4 union elements` is not assignable to `dict[str, Any]`
+ src/integrations/prefect-dbt/prefect_dbt/core/settings.py:94:28: error[invalid-assignment] Object of type `T@resolve_block_document_references | dict[str, Any]` is not assignable to `dict[str, Any]`
- src/integrations/prefect-dbt/prefect_dbt/core/settings.py:99:28: error[invalid-assignment] Object of type `int | T@resolve_variables | float | ... omitted 4 union elements` is not assignable to `dict[str, Any]`
+ src/integrations/prefect-dbt/prefect_dbt/core/settings.py:99:28: error[invalid-assignment] Object of type `T@resolve_variables | dict[str, Any]` is not assignable to `dict[str, Any]`
- src/prefect/cli/deploy/_core.py:86:21: error[invalid-assignment] Object of type `dict[str, Any] | int | T@resolve_block_document_references | ... omitted 4 union elements` is not assignable to `dict[str, Any]`
+ src/prefect/cli/deploy/_core.py:86:21: error[invalid-assignment] Object of type `T@resolve_block_document_references | dict[str, Any]` is not assignable to `dict[str, Any]`
- src/prefect/cli/deploy/_core.py:87:21: error[invalid-assignment] Object of type `int | T@resolve_variables | float | ... omitted 4 union elements` is not assignable to `dict[str, Any]`
+ src/prefect/cli/deploy/_core.py:87:21: error[invalid-assignment] Object of type `T@resolve_variables` is not assignable to `dict[str, Any]`
- src/prefect/deployments/runner.py:795:70: warning[possibly-missing-attribute] Attribute `__name__` may be missing on object of type `Unknown | (((...) -> Any) & ((*args: object, **kwargs: object) -> object))`
+ src/prefect/deployments/runner.py:795:70: warning[possibly-missing-attribute] Attribute `__name__` may be missing on object of type `Unknown | ((...) -> Any)`
- src/prefect/deployments/steps/core.py:137:38: error[invalid-argument-type] Argument is incorrect: Expected `T@resolve_variables`, found `dict[str, Any] | int | T@resolve_block_document_references | ... omitted 4 union elements`
+ src/prefect/deployments/steps/core.py:137:38: error[invalid-argument-type] Argument is incorrect: Expected `T@resolve_variables`, found `T@resolve_block_document_references | dict[str, Any]`
+ src/prefect/flow_engine.py:812:32: error[invalid-await] `Unknown | R@FlowRunEngine | Coroutine[Any, Any, R@FlowRunEngine]` is not awaitable
+ src/prefect/flow_engine.py:1401:24: error[invalid-await] `Unknown | R@AsyncFlowRunEngine | Coroutine[Any, Any, R@AsyncFlowRunEngine]` is not awaitable
+ src/prefect/flow_engine.py:1482:43: error[invalid-argument-type] Argument to function `next` is incorrect: Expected `SupportsNext[Unknown]`, found `Unknown | R@run_generator_flow_sync`
+ src/prefect/flow_engine.py:1490:21: warning[possibly-missing-attribute] Attribute `throw` may be missing on object of type `Unknown | R@run_generator_flow_sync`
+ src/prefect/flow_engine.py:1524:44: warning[possibly-missing-attribute] Attribute `__anext__` may be missing on object of type `Unknown | R@run_generator_flow_async`
+ src/prefect/flow_engine.py:1531:25: warning[possibly-missing-attribute] Attribute `throw` may be missing on object of type `Unknown | R@run_generator_flow_async`
- src/prefect/flows.py:286:34: error[unresolved-attribute] Object of type `((**P@Flow) -> R@Flow) & ((*args: object, **kwargs: object) -> object)` has no attribute `__name__`
+ src/prefect/flows.py:286:34: error[unresolved-attribute] Object of type `(**P@Flow) -> R@Flow` has no attribute `__name__`
- src/prefect/flows.py:404:68: error[unresolved-attribute] Object of type `((**P@Flow) -> R@Flow) & ((*args: object, **kwargs: object) -> object)` has no attribute `__name__`
+ src/prefect/flows.py:404:68: error[unresolved-attribute] Object of type `(**P@Flow) -> R@Flow` has no attribute `__name__`
- src/prefect/flows.py:1750:53: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
- src/prefect/utilities/templating.py:320:13: error[invalid-assignment] Invalid subscript assignment with key of type `object` and value of type `dict[str, Any] | int | T@resolve_block_document_references | ... omitted 4 union elements` on object of type `dict[str, Any]`
+ src/prefect/utilities/templating.py:320:13: error[invalid-assignment] Invalid subscript assignment with key of type `object` and value of type `T@resolve_block_document_references | dict[str, Any]` on object of type `dict[str, Any]`
- src/prefect/utilities/templating.py:323:16: error[invalid-return-type] Return type does not match returned value: expected `T@resolve_block_document_references | dict[str, Any]`, found `list[Unknown | dict[str, Any] | int | ... omitted 5 union elements]`
+ src/prefect/utilities/templating.py:323:16: error[invalid-return-type] Return type does not match returned value: expected `T@resolve_block_document_references | dict[str, Any]`, found `list[Unknown | T@resolve_block_document_references | dict[str, Any]]`
- src/prefect/utilities/templating.py:437:16: error[invalid-return-type] Return type does not match returned value: expected `T@resolve_variables`, found `dict[object, Unknown | int | T@resolve_variables | ... omitted 5 union elements]`
+ src/prefect/utilities/templating.py:437:16: error[invalid-return-type] Return type does not match returned value: expected `T@resolve_variables`, found `dict[object, Unknown | T@resolve_variables]`
- src/prefect/utilities/templating.py:442:16: error[invalid-return-type] Return type does not match returned value: expected `T@resolve_variables`, found `list[Unknown | int | T@resolve_variables | ... omitted 5 union elements]`
+ src/prefect/utilities/templating.py:442:16: error[invalid-return-type] Return type does not match returned value: expected `T@resolve_variables`, found `list[Unknown | T@resolve_variables]`
- src/prefect/workers/base.py:232:13: error[invalid-argument-type] Argument is incorrect: Expected `T@resolve_variables`, found `dict[str, Any] | int | T@resolve_block_document_references | ... omitted 4 union elements`
+ src/prefect/workers/base.py:232:13: error[invalid-argument-type] Argument is incorrect: Expected `T@resolve_variables`, found `T@resolve_block_document_references | dict[str, Any]`
- src/prefect/workers/base.py:234:20: error[invalid-argument-type] Argument expression after ** must be a mapping type: Found `int | T@resolve_variables | float | ... omitted 4 union elements`
+ src/prefect/workers/base.py:234:20: error[invalid-argument-type] Argument expression after ** must be a mapping type: Found `T@resolve_variables`
- Found 5407 diagnostics
+ Found 5412 diagnostics

static-frame (https://github.com/static-frame/static-frame)
- static_frame/core/bus.py:675:16: error[invalid-return-type] Return type does not match returned value: expected `InterGetItemILocReduces[Bus[Any], object_]`, found `InterGetItemILocReduces[Self@iloc | Bus[Any], object_ | Self@iloc]`
+ static_frame/core/bus.py:671:16: error[invalid-return-type] Return type does not match returned value: expected `InterGetItemLocReduces[Bus[Any], object_]`, found `InterGetItemLocReduces[Bus[Any] | Bottom[Series[Any, Any]] | TypeBlocks | ... omitted 6 union elements, object_]`
+ static_frame/core/bus.py:675:16: error[invalid-return-type] Return type does not match returned value: expected `InterGetItemILocReduces[Bus[Any], object_]`, found `InterGetItemILocReduces[Bus[Any] | Bottom[Index[Any]] | Bottom[Series[Any, Any]] | ... omitted 7 union elements, object_ | Self@iloc]`
+ static_frame/core/series.py:772:16: error[invalid-return-type] Return type does not match returned value: expected `InterGetItemILocReduces[Series[Any, Any], TVDtype@Series]`, found `InterGetItemILocReduces[Series[Any, Any] | IndexHierarchy | TypeBlocks | ... omitted 7 union elements, TVDtype@Series]`
+ static_frame/core/series.py:4072:16: error[invalid-return-type] Return type does not match returned value: expected `InterGetItemILocReduces[SeriesHE[Any, Any], TVDtype@SeriesHE]`, found `InterGetItemILocReduces[Bottom[Series[Any, Any]] | Bottom[Index[Any]] | TypeBlocks | ... omitted 8 union elements, TVDtype@SeriesHE]`
+ static_frame/core/yarn.py:418:16: error[invalid-return-type] Return type does not match returned value: expected `InterGetItemILocReduces[Yarn[Any], object_]`, found `InterGetItemILocReduces[Yarn[Any] | Bottom[Index[Any]] | Bottom[Series[Any, Any]] | ... omitted 7 union elements, object_]`
- Found 1821 diagnostics
+ Found 1825 diagnostics

rotki (https://github.com/rotki/rotki)
- rotkehlchen/tests/utils/mock.py:74:39: error[invalid-argument-type] Invalid `NamedTuple()` field definition: Expected a `(name, type)` tuple, found `Literal["version"]`
+ rotkehlchen/tests/utils/mock.py:74:38: error[invalid-named-tuple] Invalid argument to parameter `fields` of `NamedTuple()`: `fields` must be a sequence of literal lists or tuples

core (https://github.com/home-assistant/core)
- homeassistant/util/variance.py:47:12: error[invalid-return-type] Return type does not match returned value: expected `(**_P@ignore_variance) -> _R@ignore_variance`, found `_Wrapped[_P@ignore_variance, int | _R@ignore_variance | float | datetime, _P@ignore_variance, _R@ignore_variance | int | float | datetime]`
- Found 14470 diagnostics
+ Found 14469 diagnostics

No memory usage changes detected ✅

@astral-sh-bot
Copy link

astral-sh-bot bot commented Jan 19, 2026

ecosystem-analyzer results

Lint rule Added Removed Changed
invalid-return-type 4 1 2
invalid-argument-type 0 1 1
invalid-named-tuple 1 0 0
Total 5 2 3

Full report with detailed diff (timing results)

@AlexWaygood

This comment was marked as resolved.

@AlexWaygood AlexWaygood marked this pull request as ready for review January 19, 2026 14:39
@AlexWaygood

This comment was marked as resolved.

@AlexWaygood AlexWaygood force-pushed the alex/defer-namedtuple branch 2 times, most recently from 75fb4d6 to d46b150 Compare January 19, 2026 14:57
@AlexWaygood AlexWaygood force-pushed the alex/defer-namedtuple branch from d46b150 to b25dcc8 Compare January 19, 2026 14:58
Copy link
Member Author

Choose a reason for hiding this comment

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

Unfortunately a lot of the diff on this file is still just code moving around. I tried to keep a fairly atomic commit history on this PR, though; it may be easiest to review commit-by-commit.

@charliermarsh
Copy link
Member

Looks great.

Are we certain there are no other cases in which a dangling call could make a recursive reference? What about, e.g.:

from typing import NamedTuple

X = type("X", (NamedTuple("NT", [("field", "X | int")]),), {})

@AlexWaygood
Copy link
Member Author

AlexWaygood commented Jan 19, 2026

Looks great.

Are we certain there are no other cases in which a dangling call could make a recursive reference? What about, e.g.:

from typing import NamedTuple

X = type("X", (NamedTuple("NT", [("field", "X | int")]),), {})

Nice. That causes a panic on this branch 🙃

I think what that means is that we may have to defer inference of the bases tuple passed to type() calls as well...

@AlexWaygood
Copy link
Member Author

AlexWaygood commented Jan 19, 2026

I think what that means is that we may have to defer inference of the bases tuple passed to type() calls as well...

I'm sort-of inclined to do that as a followup? It's a real bug that we should fix, but it also feels very unlikely to come up in real code, and it'll make the diff on this PR much bigger.

I would add an x-failing mdtest with the <!-- expect-panic --> HTML comment, but it looks like the mdtest would be really slow, so I don't think it's worth it.

@AlexWaygood
Copy link
Member Author

AlexWaygood commented Jan 19, 2026

And the panic isn't new -- it's a pre-existing issue. I can trigger it on main with:

X = type("X", (tuple["X | None"],), {})

So we do just need to defer inference of the bases tuple passed to type(). I opened astral-sh/ty#2564.

@charliermarsh
Copy link
Member

Sounds good 👍

@charliermarsh
Copy link
Member

I suspect I can fix that panic once this merges given the patterns established here (unless you're on it).

@AlexWaygood
Copy link
Member Author

I suspect I can fix that panic once this merges given the patterns established here (unless you're on it).

yeah, that would be great!

@AlexWaygood
Copy link
Member Author

(this PR is one that I'd love @carljm and/or @dcreager to take a quick look at 😅)

Copy link
Member

@dcreager dcreager left a comment

Choose a reason for hiding this comment

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

No concerns with the implementation, just some clarifying questions for my own curiosity

Comment on lines +5711 to +5717
fn deferred_spec_initial<'db>(
db: &'db dyn Db,
_id: salsa::Id,
_definition: Definition<'db>,
) -> NamedTupleSpec<'db> {
NamedTupleSpec::unknown(db)
}
Copy link
Member

Choose a reason for hiding this comment

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

I like how the cycle function is right next to the tracked function itself.

Comment on lines +5784 to +5785
/// Dangling calls can always store the spec. They *can* contain
/// forward references if they appear in class bases:
Copy link
Member

Choose a reason for hiding this comment

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

Does this suggest that it's okay to have a dangling call outside of a base class list, but then the call cannot have forward/string references?

Something like

def foo(base):
    class Point(base): ...
    return Point

foo(NamedTuple("Good", (("x", int), ("y", int))))             # okay
foo(NamedTuple("Bad", (("x", "Forward"), ("y", "Forward"))))  # bad

class Forward: ...

(Or are dangling calls only allowed in base class lists?)

Copy link
Member Author

Choose a reason for hiding this comment

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

Dangling calls are allowed in arbitrary locations, but I think they can only cause problematic cycles if they appear in class bases or other locations where we know we will need to defer inference (such as the bases list passed to type() calls -- see astral-sh/ty#2564).

I would not describe myself as 100% confident that this is correct, so if you can think of any other examples where this PR branch stack-overflows or panics, I'd love to know about them 😆

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 don't emit any errors on that snippet with this branch!)

actual: Type<'db>,
polarity: TypeVarVariance,
mut f: &mut dyn FnMut(TypeVarAssignment<'db>) -> Option<Type<'db>>,
seen: &mut FxHashSet<(Type<'db>, Type<'db>)>,
Copy link
Member

Choose a reason for hiding this comment

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

I was going to suggest making seen a field of the SpecBuilder, to reduce the churn on these method signatures. But on second thought I'm not sure it would be worth it — the naive approach would mean that if we call infer_map more than once with the same formal/actual type, we would skip all inference work for the second and later calls. But the result is important, since we use it to produce "invalid argument" diagnostics. So you'd have to make seen a map, and store the method result, so that we still return the same result for later (cached) calls. And at that point I'm not convinced it's worth the effort just to avoid a new method argument.

Co-authored-by: Douglas Creager <dcreager@dcreager.net>
@AlexWaygood AlexWaygood force-pushed the alex/defer-namedtuple branch from 4ab0615 to 8c0a88a Compare January 21, 2026 15:59
@AlexWaygood AlexWaygood enabled auto-merge (squash) January 21, 2026 16:00
@AlexWaygood AlexWaygood merged commit e1b9a69 into main Jan 21, 2026
46 of 48 checks passed
@AlexWaygood AlexWaygood deleted the alex/defer-namedtuple branch January 21, 2026 16:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ecosystem-analyzer ty Multi-file analysis & type inference

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support recursive definitions for functional namedtuples Support string annotations in functional class annotations

3 participants