Skip to content

Conversation

@oconnor663
Copy link
Contributor

@astral-sh-bot
Copy link

astral-sh-bot bot commented Nov 15, 2025

Diagnostic diff on typing conformance tests

Changes were detected when running ty on typing conformance tests
--- old-output.txt	2025-11-20 20:55:37.641020926 +0000
+++ new-output.txt	2025-11-20 20:55:41.165048397 +0000
@@ -954,11 +954,15 @@
 typeddicts_extra_items.py:285:43: error[invalid-key] Unknown key "language" for TypedDict `ExtraMovie`: Unknown key "language"
 typeddicts_extra_items.py:293:44: error[invalid-key] Unknown key "year" for TypedDict `ClosedMovie`: Unknown key "year"
 typeddicts_extra_items.py:299:54: error[invalid-key] Unknown key "summary" for TypedDict `MovieExtraStr`: Unknown key "summary"
+typeddicts_extra_items.py:300:34: error[invalid-assignment] Object of type `MovieExtraStr` is not assignable to `Mapping[str, str]`
 typeddicts_extra_items.py:302:54: error[invalid-key] Unknown key "year" for TypedDict `MovieExtraInt`: Unknown key "year"
+typeddicts_extra_items.py:303:34: error[invalid-assignment] Object of type `MovieExtraInt` is not assignable to `Mapping[str, int]`
+typeddicts_extra_items.py:304:44: error[invalid-assignment] Object of type `MovieExtraInt` is not assignable to `Mapping[str, int | str]`
 typeddicts_extra_items.py:310:5: error[type-assertion-failure] Type `list[tuple[str, int | str]]` does not match asserted type `list[tuple[str, object]]`
 typeddicts_extra_items.py:311:5: error[type-assertion-failure] Type `list[int | str]` does not match asserted type `list[object]`
-typeddicts_extra_items.py:327:5: error[unresolved-attribute] Object of type `IntDict` has no attribute `clear`
+typeddicts_extra_items.py:326:25: error[invalid-assignment] Object of type `IntDict` is not assignable to `dict[str, int]`
 typeddicts_extra_items.py:329:52: error[invalid-key] Unknown key "bar" for TypedDict `IntDictWithNum` - did you mean "num"?
+typeddicts_extra_items.py:330:32: error[invalid-assignment] Object of type `IntDictWithNum` is not assignable to `dict[str, int]`
 typeddicts_extra_items.py:337:1: error[unresolved-attribute] Object of type `IntDictWithNum` has no attribute `clear`
 typeddicts_extra_items.py:339:1: error[type-assertion-failure] Type `tuple[str, int]` does not match asserted type `Unknown`
 typeddicts_extra_items.py:339:13: error[unresolved-attribute] Object of type `IntDictWithNum` has no attribute `popitem`
@@ -980,12 +984,26 @@
 typeddicts_readonly.py:51:4: error[invalid-assignment] Cannot assign to key "year" on TypedDict `Movie1`: key is marked read-only
 typeddicts_readonly.py:60:4: error[invalid-assignment] Cannot assign to key "title" on TypedDict `Movie2`: key is marked read-only
 typeddicts_readonly.py:61:4: error[invalid-assignment] Cannot assign to key "year" on TypedDict `Movie2`: key is marked read-only
+typeddicts_readonly_consistency.py:37:14: error[invalid-assignment] Object of type `A1` is not assignable to `B1`
+typeddicts_readonly_consistency.py:38:14: error[invalid-assignment] Object of type `C1` is not assignable to `B1`
+typeddicts_readonly_consistency.py:40:14: error[invalid-assignment] Object of type `A1` is not assignable to `C1`
+typeddicts_readonly_consistency.py:81:14: error[invalid-assignment] Object of type `A2` is not assignable to `B2`
+typeddicts_readonly_consistency.py:82:14: error[invalid-assignment] Object of type `C2` is not assignable to `B2`
+typeddicts_readonly_consistency.py:84:14: error[invalid-assignment] Object of type `A2` is not assignable to `C2`
+typeddicts_readonly_consistency.py:85:14: error[invalid-assignment] Object of type `B2` is not assignable to `C2`
 typeddicts_readonly_inheritance.py:36:4: error[invalid-assignment] Cannot assign to key "name" on TypedDict `Album2`: key is marked read-only
 typeddicts_readonly_inheritance.py:65:19: error[missing-typed-dict-key] Missing required key 'name' in TypedDict `RequiredName` constructor
 typeddicts_readonly_inheritance.py:82:14: error[invalid-assignment] Invalid assignment to key "ident" with declared type `str` on TypedDict `User`: value of type `Literal[3]`
 typeddicts_readonly_inheritance.py:83:15: error[invalid-argument-type] Invalid argument to key "ident" with declared type `str` on TypedDict `User`: value of type `Literal[3]`
 typeddicts_readonly_inheritance.py:84:5: error[missing-typed-dict-key] Missing required key 'ident' in TypedDict `User` constructor
+typeddicts_type_consistency.py:21:10: error[invalid-assignment] Object of type `B1` is not assignable to `A1`
+typeddicts_type_consistency.py:38:10: error[invalid-assignment] Object of type `B2` is not assignable to `A2`
+typeddicts_type_consistency.py:65:6: error[invalid-assignment] Object of type `A3` is not assignable to `B3`
 typeddicts_type_consistency.py:69:21: error[invalid-key] Unknown key "y" for TypedDict `A3`: Unknown key "y"
+typeddicts_type_consistency.py:76:22: error[invalid-assignment] Object of type `B3` is not assignable to `dict[str, int]`
+typeddicts_type_consistency.py:77:25: error[invalid-assignment] Object of type `B3` is not assignable to `dict[str, object]`
+typeddicts_type_consistency.py:78:22: error[invalid-assignment] Object of type `B3` is not assignable to `dict[Any, Any]`
+typeddicts_type_consistency.py:82:25: error[invalid-assignment] Object of type `B3` is not assignable to `Mapping[str, int]`
 typeddicts_type_consistency.py:101:14: error[invalid-assignment] Object of type `Unknown | None` is not assignable to `str`
 typeddicts_type_consistency.py:126:56: error[invalid-argument-type] Invalid argument to key "inner_key" with declared type `str` on TypedDict `Inner1`: value of type `Literal[1]`
 typeddicts_usage.py:23:7: error[invalid-key] Unknown key "director" for TypedDict `Movie`: Unknown key "director"
@@ -993,5 +1011,5 @@
 typeddicts_usage.py:28:17: error[missing-typed-dict-key] Missing required key 'name' in TypedDict `Movie` constructor
 typeddicts_usage.py:28:18: error[invalid-key] Unknown key "title" for TypedDict `Movie`: Unknown key "title"
 typeddicts_usage.py:40:24: error[invalid-type-form] The special form `typing.TypedDict` is not allowed in type expressions. Did you mean to use a concrete TypedDict or `collections.abc.Mapping[str, object]` instead?
-Found 995 diagnostics
+Found 1013 diagnostics
 WARN A fatal error occurred while checking some files. Not all project files were analyzed. See the diagnostics list above for details.


class Person(TypedDict):
name: str
phone_number: str
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This test wants to look at the diagnostics we print for TypedDicts in a union, but previously Person was a subtype of Animal, so this PR made the union disappear. Adding phone_number breaks the subtyping relationship, so the union remains.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Actually if we end up going with "don't generally simplify Unions of TypedDicts", I can put this test back the way it was.

Copy link
Member

Choose a reason for hiding this comment

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

I think it's better for the test to pass whichever way we go on that, so this change seems good to me :-)

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah I expect if we land more comprehensive cycle-panic avoidance, we might want to go back to simplifying typed dicts in unions.

@astral-sh-bot
Copy link

astral-sh-bot bot commented Nov 15, 2025

mypy_primer results

Changes were detected when running on open source projects
aioredis (https://github.com/aio-libs/aioredis)
+ aioredis/connection.py:1301:9: error[no-matching-overload] No overload of bound method `update` matches arguments
- Found 25 diagnostics
+ Found 26 diagnostics

kornia (https://github.com/kornia/kornia)
+ kornia/feature/adalam/core.py:399:96: error[no-matching-overload] No overload of bound method `__init__` matches arguments
- Found 766 diagnostics
+ Found 767 diagnostics

pytest (https://github.com/pytest-dev/pytest)
+ testing/typing_checks.py:47:25: error[invalid-argument-type] Argument to bound method `setitem` is incorrect: Expected `Mapping[Literal["x"], Literal[2]]`, found `Foo`
+ testing/typing_checks.py:48:25: error[invalid-argument-type] Argument to bound method `delitem` is incorrect: Expected `Mapping[Literal["y"], Unknown]`, found `Foo`
- Found 445 diagnostics
+ Found 447 diagnostics

poetry (https://github.com/python-poetry/poetry)
+ src/poetry/utils/authenticator.py:224:9: error[no-matching-overload] No overload of bound method `update` matches arguments
- Found 978 diagnostics
+ Found 979 diagnostics

freqtrade (https://github.com/freqtrade/freqtrade)
- freqtrade/rpc/telegram.py:445:77: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
+ freqtrade/rpc/telegram.py:543:46: error[invalid-argument-type] Argument to bound method `_format_entry_msg` is incorrect: Expected `RPCEntryMsg`, found `RPCStatusMsg | RPCStrategyMsg | RPCProtectionMsg | ... omitted 7 union elements`
+ freqtrade/rpc/telegram.py:546:45: error[invalid-argument-type] Argument to bound method `_format_exit_msg` is incorrect: Expected `RPCExitMsg`, found `RPCStatusMsg | RPCStrategyMsg | RPCProtectionMsg | ... omitted 7 union elements`
+ freqtrade/rpc/telegram.py:553:62: error[invalid-argument-type] Argument to bound method `_exchange_from_msg` is incorrect: Expected `RPCEntryMsg | RPCExitMsg | RPCExitCancelMsg | RPCCancelMsg`, found `RPCStatusMsg | RPCStrategyMsg | RPCProtectionMsg | ... omitted 7 union elements`
- Found 610 diagnostics
+ Found 612 diagnostics

meson (https://github.com/mesonbuild/meson)
+ mesonbuild/build.py:1274:42: error[invalid-argument-type] Argument to function `extract_as_list` is incorrect: Expected `dict[Literal["link_args"], Unknown]`, found `BuildTargetKeywordArguments`
+ mesonbuild/build.py:1288:35: error[invalid-argument-type] Argument to function `extract_as_list` is incorrect: Expected `dict[Literal["include_directories"], Unknown]`, found `BuildTargetKeywordArguments`
+ mesonbuild/build.py:1291:35: error[invalid-argument-type] Argument to function `extract_as_list` is incorrect: Expected `dict[Literal["dependencies"], Unknown]`, found `BuildTargetKeywordArguments`
+ mesonbuild/build.py:2645:40: error[invalid-argument-type] Argument to bound method `process_vs_module_defs_kw` is incorrect: Expected `ExecutableKeywordArguments`, found `SharedLibraryKeywordArguments`
+ mesonbuild/interpreter/interpreter.py:1303:87: error[invalid-argument-type] Argument to function `machine_from_native_kwarg` is incorrect: Expected `dict[str, Any]`, found `FuncAddLanguages`
- mesonbuild/interpreter/interpreter.py:1874:16: error[invalid-return-type] Return type does not match returned value: expected `SharedModule`, found `SharedLibrary`
+ mesonbuild/interpreter/interpreter.py:1905:20: error[no-matching-overload] No overload of bound method `build_target` matches arguments
+ mesonbuild/interpreter/interpreter.py:1916:16: error[no-matching-overload] No overload of bound method `build_target` matches arguments
+ mesonbuild/interpreter/interpreter.py:2215:35: error[invalid-argument-type] Argument to bound method `add_test` is incorrect: Expected `dict[str, Any]`, found `FuncBenchmark`
+ mesonbuild/interpreter/interpreter.py:2222:35: error[invalid-argument-type] Argument to bound method `add_test` is incorrect: Expected `dict[str, Any]`, found `FuncTest`
+ mesonbuild/interpreter/interpreter.py:2253:37: error[invalid-argument-type] Argument to bound method `unpack_env_kwarg` is incorrect: Expected `EnvironmentVariables | dict[str, @Todo] | list[@Todo] | str`, found `BaseTest`
- mesonbuild/interpreter/interpreter.py:3452:16: error[invalid-key] Unknown key "dependencies" for TypedDict `SharedModule`: Unknown key "dependencies"
+ mesonbuild/interpreter/interpreter.py:3452:50: error[invalid-argument-type] Argument to function `extract_as_list` is incorrect: Expected `dict[Literal["dependencies"], Unknown]`, found `Executable | StaticLibrary | SharedLibrary | Jar`
- mesonbuild/interpreter/interpreter.py:3454:33: error[invalid-assignment] Invalid assignment to key "extra_files" with declared type `list[File | str]` on TypedDict `SharedModule`: value of type `list[File]`
+ mesonbuild/interpreter/interpreter.py:3456:38: error[invalid-argument-type] Argument to bound method `__process_language_args` is incorrect: Expected `dict[str, list[File | str]]`, found `Executable | StaticLibrary | SharedLibrary | Jar`
- mesonbuild/interpreter/interpreter.py:3484:39: error[invalid-assignment] Invalid assignment to key "d_import_dirs" with declared type `list[str | IncludeDirs]` on TypedDict `SharedModule`: value of type `list[IncludeDirs]`
- mesonbuild/interpreter/mesonmain.py:385:55: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
+ mesonbuild/modules/python.py:158:45: error[invalid-argument-type] Argument to function `extract_as_list` is incorrect: Expected `dict[Literal["dependencies"], Unknown]`, found `ExtensionModuleKw`
+ mesonbuild/modules/python.py:178:51: error[invalid-argument-type] Argument to function `extract_as_list` is incorrect: Expected `dict[Literal["c_args"], Unknown]`, found `ExtensionModuleKw`
+ mesonbuild/modules/python.py:182:53: error[invalid-argument-type] Argument to function `extract_as_list` is incorrect: Expected `dict[Literal["cpp_args"], Unknown]`, found `ExtensionModuleKw`
+ mesonbuild/modules/python.py:207:58: error[invalid-argument-type] Argument to function `extract_as_list` is incorrect: Expected `dict[Literal["link_args"], Unknown]`, found `ExtensionModuleKw`
- mesonbuild/modules/python.py:231:16: error[invalid-return-type] Return type does not match returned value: expected `SharedModule`, found `Unknown | SharedLibrary`
+ mesonbuild/modules/simd.py:103:54: error[invalid-argument-type] Argument to function `extract_as_list` is incorrect: Expected `dict[str, Unknown]`, found `StaticLibrary`
- Found 1722 diagnostics
+ Found 1733 diagnostics

discord.py (https://github.com/Rapptz/discord.py)
- discord/audit_logs.py:414:67: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
- discord/channel.py:561:87: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
- discord/channel.py:1654:87: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
- discord/channel.py:2010:87: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
- discord/channel.py:2169:87: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
- discord/channel.py:2815:87: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
+ discord/channel.py:3234:57: error[invalid-argument-type] Argument to function `store_user` is incorrect: Argument type `PartialUser` does not satisfy upper bound `ConnectionState[ClientT@ConnectionState]` of type variable `Self`
+ discord/channel.py:3409:63: error[invalid-argument-type] Argument to function `store_user` is incorrect: Argument type `PartialUser` does not satisfy upper bound `ConnectionState[ClientT@ConnectionState]` of type variable `Self`
+ discord/components.py:1326:27: error[invalid-argument-type] Invalid argument to key "components" with declared type `list[ActionRow | TextComponent | MediaGalleryComponent | ... omitted 5 union elements]` on TypedDict `ContainerComponent`: value of type `list[ButtonComponent | SelectMenu | TextInput | ... omitted 11 union elements]`
- discord/components.py:1382:53: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
- discord/components.py:1464:34: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
+ discord/components.py:1458:26: error[invalid-argument-type] Argument to bound method `__init__` is incorrect: Expected `ActionRow`, found `ButtonComponent | SelectMenu | TextInput | ... omitted 10 union elements`
+ discord/components.py:1460:23: error[invalid-argument-type] Argument to bound method `__init__` is incorrect: Expected `ButtonComponent`, found `ButtonComponent | SelectMenu | TextInput | ... omitted 10 union elements`
+ discord/components.py:1462:26: error[invalid-argument-type] Argument to bound method `__init__` is incorrect: Expected `TextInput`, found `ButtonComponent | SelectMenu | TextInput | ... omitted 10 union elements`
+ discord/components.py:1466:33: error[invalid-argument-type] Argument to bound method `__init__` is incorrect: Expected `SectionComponent`, found `ButtonComponent | SelectMenu | TextInput | ... omitted 10 union elements`
+ discord/components.py:1468:28: error[invalid-argument-type] Argument to bound method `__init__` is incorrect: Expected `TextComponent`, found `ButtonComponent | SelectMenu | TextInput | ... omitted 10 union elements`
+ discord/components.py:1470:35: error[invalid-argument-type] Argument to bound method `__init__` is incorrect: Expected `ThumbnailComponent`, found `ButtonComponent | SelectMenu | TextInput | ... omitted 10 union elements`
+ discord/components.py:1472:38: error[invalid-argument-type] Argument to bound method `__init__` is incorrect: Expected `MediaGalleryComponent`, found `ButtonComponent | SelectMenu | TextInput | ... omitted 10 union elements`
+ discord/components.py:1474:30: error[invalid-argument-type] Argument to bound method `__init__` is incorrect: Expected `FileComponent`, found `ButtonComponent | SelectMenu | TextInput | ... omitted 10 union elements`
+ discord/components.py:1476:35: error[invalid-argument-type] Argument to bound method `__init__` is incorrect: Expected `SeparatorComponent`, found `ButtonComponent | SelectMenu | TextInput | ... omitted 10 union elements`
+ discord/components.py:1478:26: error[invalid-argument-type] Argument to bound method `__init__` is incorrect: Expected `ContainerComponent`, found `ButtonComponent | SelectMenu | TextInput | ... omitted 10 union elements`
+ discord/components.py:1480:31: error[invalid-argument-type] Argument to bound method `__init__` is incorrect: Expected `LabelComponent`, found `ButtonComponent | SelectMenu | TextInput | ... omitted 10 union elements`
+ discord/components.py:1482:36: error[invalid-argument-type] Argument to bound method `__init__` is incorrect: Expected `FileUploadComponent`, found `ButtonComponent | SelectMenu | TextInput | ... omitted 10 union elements`
- discord/guild.py:653:73: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
+ discord/guild.py:1961:13: error[invalid-argument-type] Argument to bound method `__init__` is incorrect: Expected `ForumChannel | MediaChannel`, found `TextChannel | NewsChannel | VoiceChannel | ... omitted 5 union elements`
- discord/interactions.py:243:106: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
+ discord/interactions.py:256:92: error[invalid-argument-type] Argument to bound method `format_map` is incorrect: Expected `_FormatMapMapping`, found `TextChannel | NewsChannel | VoiceChannel | ... omitted 7 union elements`
+ discord/member.py:319:45: error[invalid-argument-type] Argument to function `store_user` is incorrect: Argument type `User` does not satisfy upper bound `ConnectionState[ClientT@ConnectionState]` of type variable `Self`
- discord/message.py:535:68: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
- discord/message.py:791:77: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
+ discord/message.py:2455:46: error[invalid-argument-type] Argument to function `store_user` is incorrect: Argument type `User` does not satisfy upper bound `ConnectionState[ClientT@ConnectionState]` of type variable `Self`
+ discord/message.py:2481:47: error[invalid-argument-type] Argument to function `store_user` is incorrect: Argument type `UserWithMember` does not satisfy upper bound `ConnectionState[ClientT@ConnectionState]` of type variable `Self`
- discord/onboarding.py:280:95: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
+ discord/scheduled_event.py:150:63: error[invalid-argument-type] Argument to function `store_user` is incorrect: Argument type `User & ~AlwaysFalsy` does not satisfy upper bound `ConnectionState[ClientT@ConnectionState]` of type variable `Self`
- discord/state.py:676:52: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
- discord/state.py:912:47: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
- discord/state.py:1158:70: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
+ discord/state.py:1121:32: error[invalid-argument-type] Argument to function `store_user` is incorrect: Argument type `User` does not satisfy upper bound `ConnectionState[ClientT@ConnectionState]` of type variable `Self`
+ discord/state.py:1388:36: error[invalid-argument-type] Argument to function `store_user` is incorrect: Argument type `User` does not satisfy upper bound `ConnectionState[ClientT@ConnectionState]` of type variable `Self`
- discord/state.py:1975:52: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
+ discord/sticker.py:420:60: error[invalid-argument-type] Argument to function `store_user` is incorrect: Argument type `User & ~AlwaysFalsy` does not satisfy upper bound `ConnectionState[ClientT@ConnectionState]` of type variable `Self`
- discord/ui/view.py:1064:74: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
+ discord/webhook/async_.py:751:44: error[invalid-argument-type] Argument to function `store_user` is incorrect: Argument type `User | PartialUser` does not satisfy upper bound `ConnectionState[ClientT@ConnectionState]` of type variable `Self`
- Found 478 diagnostics
+ Found 485 diagnostics

prefect (https://github.com/PrefectHQ/prefect)
+ src/prefect/settings/profiles.py:103:56: error[invalid-argument-type] Argument to bound method `from_exception_data` is incorrect: Expected `list[InitErrorDetails]`, found `list[Unknown | ErrorDetails]`
- Found 3239 diagnostics
+ Found 3240 diagnostics

openlibrary (https://github.com/internetarchive/openlibrary)
+ openlibrary/core/lists/model.py:431:13: error[invalid-assignment] Object of type `(Thing & ~str & ~Top[dict[Unknown, Unknown]]) | (AnnotatedSeed & ~str & ~Top[dict[Unknown, Unknown]])` is not assignable to attribute `value` of type `Thing | str`
+ openlibrary/core/lists/model.py:474:27: error[invalid-argument-type] Argument to bound method `__init__` is incorrect: Expected `Thing | str | AnnotatedSeed`, found `str | ThingReferenceDict | AnnotatedSeedDict`
- Found 944 diagnostics
+ Found 946 diagnostics

hydra-zen (https://github.com/mit-ll-responsible-ai/hydra-zen)
+ src/hydra_zen/structured_configs/_utils.py:257:22: error[invalid-key] TypedDict `AllConvert` can only be subscripted with a string literal key, got key of type `str`.
- Found 543 diagnostics
+ Found 544 diagnostics

cwltool (https://github.com/common-workflow-language/cwltool)
+ cwltool/cwlprov/ro.py:357:21: error[no-matching-overload] No overload of bound method `update` matches arguments
- Found 151 diagnostics
+ Found 152 diagnostics

altair (https://github.com/vega/altair)
- altair/vegalite/v6/data.py:27:44: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
+ altair/vegalite/v6/data.py:25:36: error[invalid-argument-type] Argument to bound method `register` is incorrect: Expected `((...) -> typing.TypeVar) | None`, found `Overload[(data: None = ellipsis, prefix: str = ellipsis, extension: str = ellipsis, filename: str = ellipsis, urlpath: str = ellipsis) -> partial[Unknown], (data: dict[Any, Any] | @Todo | SupportsGeoInterface | DataFrameLike, prefix: str = ellipsis, extension: str = ellipsis, filename: str = ellipsis, urlpath: str = ellipsis) -> _ToFormatReturnUrlDict]`
- tests/vegalite/v6/test_api.py:564:17: error[invalid-key] Unknown key "condition" for TypedDict `_Value`: Unknown key "condition"
- tests/vegalite/v6/test_api.py:610:32: error[invalid-key] Unknown key "condition" for TypedDict `_Value`: Unknown key "condition"
- Found 992 diagnostics
+ Found 990 diagnostics

pandas-stubs (https://github.com/pandas-dev/pandas-stubs)
- tests/test_timefuncs.py:1352:11: error[type-assertion-failure] Type `Series[Timestamp]` does not match asserted type `Timestamp`
- tests/test_timefuncs.py:1353:11: error[type-assertion-failure] Type `Series[Timestamp]` does not match asserted type `Timestamp`
- Found 5966 diagnostics
+ Found 5964 diagnostics

zulip (https://github.com/zulip/zulip)
- zerver/tests/test_user_status.py:23:12: error[invalid-return-type] Return type does not match returned value: expected `UserInfoDict`, found `UserInfoDict | None`
+ zerver/views/auth.py:362:30: error[no-matching-overload] No overload of bound method `__init__` matches arguments

core (https://github.com/home-assistant/core)
- homeassistant/components/auth/login_flow.py:226:24: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
- homeassistant/components/auth/mfa_setup_flow.py:157:24: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
+ homeassistant/components/auth/mfa_setup_flow.py:155:16: error[no-matching-overload] No overload of bound method `__init__` matches arguments
+ homeassistant/components/conversation/default_agent.py:274:33: error[invalid-key] Unknown key "changes" for TypedDict `_EventEntityRegistryUpdatedData_CreateRemove`: Unknown key "changes"
+ homeassistant/components/energy/data.py:367:29: error[invalid-assignment] Invalid assignment to key "energy_sources" with declared type `list[SourceType]` on TypedDict `EnergyPreferences`: value of type `list[SourceType] | list[DeviceConsumption]`
+ homeassistant/components/energy/data.py:367:29: error[invalid-assignment] Invalid assignment to key "device_consumption" with declared type `list[DeviceConsumption]` on TypedDict `EnergyPreferences`: value of type `list[SourceType] | list[DeviceConsumption]`
- homeassistant/components/evohome/storage.py:85:45: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
+ homeassistant/components/fronius/config_flow.py:119:53: error[no-matching-overload] No overload of bound method `__init__` matches arguments
+ homeassistant/components/geofency/device_tracker.py:71:9: error[invalid-assignment] Object of type `DeviceInfo` is not assignable to attribute `_attr_device_info` of type `None`
+ homeassistant/components/gpslogger/device_tracker.py:83:9: error[invalid-assignment] Object of type `DeviceInfo` is not assignable to attribute `_attr_device_info` of type `None`
- homeassistant/components/homekit/config_flow.py:502:25: error[invalid-assignment] Object of type `Any` is not assignable to `EntityFilterDict`
- homeassistant/components/homekit/config_flow.py:546:43: error[invalid-assignment] Object of type `Any` is not assignable to `EntityFilterDict`
- homeassistant/components/homekit/config_flow.py:649:43: error[invalid-assignment] Object of type `Any` is not assignable to `EntityFilterDict`
+ homeassistant/components/launch_library/__init__.py:56:9: error[invalid-argument-type] Argument to bound method `__init__` is incorrect: Expected `(() -> Awaitable[dict[str, Any]]) | None`, found `def async_update() -> CoroutineType[Any, Any, LaunchLibraryData]`
+ homeassistant/components/mqtt/config_flow.py:3376:65: error[no-matching-overload] No overload of bound method `__init__` matches arguments
+ homeassistant/components/mqtt/config_flow.py:4589:13: error[no-matching-overload] No overload of bound method `update` matches arguments
+ homeassistant/components/mqtt/config_flow.py:4590:13: error[no-matching-overload] No overload of bound method `update` matches arguments
+ homeassistant/components/mqtt/config_flow.py:4626:9: error[no-matching-overload] No overload of bound method `update` matches arguments
+ homeassistant/components/mqtt/config_flow.py:4638:13: error[no-matching-overload] No overload of bound method `update` matches arguments
+ homeassistant/components/mqtt/config_flow.py:4639:13: error[no-matching-overload] No overload of bound method `update` matches arguments
+ homeassistant/components/mqtt/entity.py:382:17: error[no-matching-overload] No overload of bound method `update` matches arguments
+ homeassistant/components/mqtt/entity.py:383:17: error[no-matching-overload] No overload of bound method `update` matches arguments
+ homeassistant/components/traccar/device_tracker.py:129:9: error[invalid-assignment] Object of type `DeviceInfo` is not assignable to attribute `_attr_device_info` of type `None`
+ homeassistant/components/weather/__init__.py:703:46: error[no-matching-overload] No overload of bound method `__init__` matches arguments
+ homeassistant/config_entries.py:3911:33: error[invalid-argument-type] Method `__getitem__` of type `(Overload[(key: Literal["action"], /) -> Literal["create", "remove"], (key: Literal["entity_id"], /) -> str]) | (Overload[(key: Literal["action"], /) -> Literal["update"], (key: Literal["entity_id"], /) -> str, (key: Literal["changes"], /) -> dict[str, Any], (key: Literal["old_entity_id"], /) -> str])` cannot be called with key of type `Literal["changes"]` on object of type `EventEntityRegistryUpdatedData`
+ homeassistant/config_entries.py:3912:12: error[invalid-argument-type] Method `__getitem__` of type `(Overload[(key: Literal["action"], /) -> Literal["create", "remove"], (key: Literal["entity_id"], /) -> str]) | (Overload[(key: Literal["action"], /) -> Literal["update"], (key: Literal["entity_id"], /) -> str, (key: Literal["changes"], /) -> dict[str, Any], (key: Literal["old_entity_id"], /) -> str])` cannot be called with key of type `Literal["changes"]` on object of type `EventEntityRegistryUpdatedData`
+ homeassistant/helpers/device_registry.py:1456:13: error[invalid-argument-type] Argument to bound method `async_fire_internal` is incorrect: Expected `EventType[_EventDeviceRegistryUpdatedData_Remove] | str`, found `EventType[EventDeviceRegistryUpdatedData]`
+ homeassistant/helpers/device_registry.py:1861:36: error[invalid-argument-type] Method `__getitem__` of type `(Overload[(key: Literal["action"], /) -> Literal["create", "remove"], (key: Literal["entity_id"], /) -> str]) | (Overload[(key: Literal["action"], /) -> Literal["update"], (key: Literal["entity_id"], /) -> str, (key: Literal["changes"], /) -> dict[str, Any], (key: Literal["old_entity_id"], /) -> str])` cannot be called with key of type `Literal["changes"]` on object of type `EventEntityRegistryUpdatedData`
+ homeassistant/helpers/entity_registry.py:1079:13: error[invalid-argument-type] Argument to bound method `async_fire_internal` is incorrect: Expected `EventType[_EventEntityRegistryUpdatedData_CreateRemove] | str`, found `EventType[EventEntityRegistryUpdatedData]`
+ homeassistant/helpers/entity_registry.py:1126:13: error[invalid-argument-type] Argument to bound method `async_fire_internal` is incorrect: Expected `EventType[_EventEntityRegistryUpdatedData_CreateRemove] | str`, found `EventType[EventEntityRegistryUpdatedData]`
+ homeassistant/helpers/entity_registry.py:1378:43: error[invalid-argument-type] Argument to bound method `async_fire_internal` is incorrect: Expected `EventType[_EventEntityRegistryUpdatedData_Update] | str`, found `EventType[EventEntityRegistryUpdatedData]`
- Found 14498 diagnostics
+ Found 14517 diagnostics

pydantic (https://github.com/pydantic/pydantic)
+ pydantic/_internal/_discriminated_union.py:223:13: error[invalid-argument-type] Argument to function `tagged_union_schema` is incorrect: Expected `SimpleSerSchema | PlainSerializerFunctionSerSchema | WrapSerializerFunctionSerSchema | ... omitted 4 union elements`, found `SimpleSerSchema | PlainSerializerFunctionSerSchema | WrapSerializerFunctionSerSchema | ... omitted 6 union elements`
+ pydantic/_internal/_discriminated_union.py:270:83: error[invalid-argument-type] Argument to bound method `_is_discriminator_shared` is incorrect: Expected `TaggedUnionSchema`, found `InvalidSchema | AnySchema | NoneSchema | ... omitted 49 union elements`
+ pydantic/_internal/_discriminated_union.py:342:70: error[invalid-argument-type] Argument to bound method `_infer_discriminator_values_for_model_choice` is incorrect: Expected `ModelFieldsSchema`, found `InvalidSchema | AnySchema | NoneSchema | ... omitted 49 union elements`
+ pydantic/_internal/_discriminated_union.py:345:74: error[invalid-argument-type] Argument to bound method `_infer_discriminator_values_for_dataclass_choice` is incorrect: Expected `DataclassArgsSchema`, found `InvalidSchema | AnySchema | NoneSchema | ... omitted 49 union elements`
+ pydantic/_internal/_discriminated_union.py:348:75: error[invalid-argument-type] Argument to bound method `_infer_discriminator_values_for_typed_dict_choice` is incorrect: Expected `TypedDictSchema`, found `InvalidSchema | AnySchema | NoneSchema | ... omitted 49 union elements`
+ pydantic/_internal/_generate_schema.py:270:34: error[invalid-assignment] Invalid assignment to key "items_schema" with declared type `list[InvalidSchema | AnySchema | NoneSchema | ... omitted 49 union elements]` on TypedDict `TupleSchema`: value of type `InvalidSchema | AnySchema | NoneSchema | ... omitted 49 union elements`
+ pydantic/_internal/_generate_schema.py:2823:77: error[invalid-argument-type] Argument to function `_inlining_behavior` is incorrect: Expected `DefinitionReferenceSchema`, found `InvalidSchema | AnySchema | NoneSchema | ... omitted 49 union elements`
+ pydantic/functional_validators.py:238:17: error[invalid-argument-type] Argument to function `with_info_plain_validator_function` is incorrect: Expected `SimpleSerSchema | PlainSerializerFunctionSerSchema | WrapSerializerFunctionSerSchema | ... omitted 4 union elements`, found `SimpleSerSchema | PlainSerializerFunctionSerSchema | WrapSerializerFunctionSerSchema | ... omitted 6 union elements`
+ pydantic/functional_validators.py:245:17: error[invalid-argument-type] Argument to function `no_info_plain_validator_function` is incorrect: Expected `SimpleSerSchema | PlainSerializerFunctionSerSchema | WrapSerializerFunctionSerSchema | ... omitted 4 union elements`, found `SimpleSerSchema | PlainSerializerFunctionSerSchema | WrapSerializerFunctionSerSchema | ... omitted 6 union elements`
+ pydantic/json_schema.py:563:49: error[invalid-argument-type] Argument to function `populate_defs` is incorrect: Expected `InvalidSchema | AnySchema | NoneSchema | ... omitted 49 union elements`, found `InvalidSchema | AnySchema | NoneSchema | ... omitted 53 union elements`
+ pydantic/json_schema.py:585:41: error[invalid-argument-type] Argument to function `populate_defs` is incorrect: Expected `InvalidSchema | AnySchema | NoneSchema | ... omitted 49 union elements`, found `InvalidSchema | AnySchema | NoneSchema | ... omitted 53 union elements`
- pydantic/json_schema.py:1808:30: error[invalid-assignment] Object of type `Any | None` is not assignable to `ConfigDict`
+ pydantic/json_schema.py:2857:16: error[invalid-return-type] Return type does not match returned value: expected `PlainSerializerFunctionSerSchema | None`, found `(SimpleSerSchema & ~AlwaysFalsy) | (PlainSerializerFunctionSerSchema & ~AlwaysFalsy) | (WrapSerializerFunctionSerSchema & ~AlwaysFalsy) | ... omitted 5 union elements`
+ pydantic/types.py:3135:13: error[invalid-argument-type] Argument to function `tagged_union_schema` is incorrect: Expected `SimpleSerSchema | PlainSerializerFunctionSerSchema | WrapSerializerFunctionSerSchema | ... omitted 4 union elements`, found `SimpleSerSchema | PlainSerializerFunctionSerSchema | WrapSerializerFunctionSerSchema | ... omitted 6 union elements`
- Found 4829 diagnostics
+ Found 4841 diagnostics

No memory usage changes detected ✅

Comment on lines +237 to +207
if self_item_field.is_required() {
// A required field can't be assigned to a not-required, mutable field
// in the target, because `del` is allowed on the target field.
return ConstraintSet::from(false);
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This rule was present in historical versions of the typing spec, but it's missing from the current version. It is mentioned and tested in the conformance suite. After this lands I'll open a PR to the typing docs upstream.

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@AlexWaygood AlexWaygood added the ty Multi-file analysis & type inference label Nov 15, 2025
@astral-sh-bot
Copy link

astral-sh-bot bot commented Nov 15, 2025

ecosystem-analyzer results

Lint rule Added Removed Changed
invalid-argument-type 94 0 1
no-matching-overload 23 0 0
unused-ignore-comment 0 23 0
invalid-assignment 7 6 1
invalid-return-type 1 3 1
invalid-key 0 4 0
type-assertion-failure 0 2 0
unsupported-operator 2 0 0
Total 127 38 3

Full report with detailed diff (timing results)

@oconnor663
Copy link
Contributor Author

It seems pretty common in the ecosystem report to want to pass a TypedDict where a dict is required (but you know it won't be modified), like as an argument to dict.update. That's kind of a bummer.

@carljm
Copy link
Contributor

carljm commented Nov 15, 2025

It seems pretty common in the ecosystem report to want to pass a TypedDict where a dict is required (but you know it won't be modified), like as an argument to dict.update. That's kind of a bummer.

If there are cases like this showing up a lot in the ecosystem, can we just verify quick that we aren't being more strict than other type checkers? If not, then these are just true positives (at least as far as the specified type system is concerned) in the ecosystem that we are catching, great. If so, maybe other type checkers are implementing some kind of pragmatic compromise that we should at least consider.

Copy link
Member

@AlexWaygood AlexWaygood left a comment

Choose a reason for hiding this comment

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

Thank you!! I haven't looked through the whole PR yet, but spotted one significant thing

@AlexWaygood
Copy link
Member

Diagnostic diff on typing conformance tests

It looks like there are a few new false positives on the typing conformance suite file typeddicts_extra_items.py -- places where we're not meant to emit diagnostic, but we now do:

I guess this is just because we don't support extra_items at all yet? So maybe this is just all covered by the "PEP 728 support" bullet point in astral-sh/ty#154.

Other than the lines listed above, all other new diagnostics on the conformance suite look like true positives to me -- great job!

@AlexWaygood
Copy link
Member

AlexWaygood commented Nov 16, 2025

Unfortunately it looks like there is a huge slowdown on the pydantic benchmark on this branch, which is causing it to timeout in CI currently. On my macbook locally, the benchmark completes in 2.161s on main, but 1.017m (I assume m here stands for minutes!) on this branch.

I suggest tackling some of the correctness issues I pointed out in #21467 (comment) and #21467 (comment) before looking at this too much. There's always a chance that fixing one/both of those will "miraculously" fix the regression 😆

@oconnor663
Copy link
Contributor Author

It seems pretty common in the ecosystem report to want to pass a TypedDict where a dict is required (but you know it won't be modified), like as an argument to dict.update. That's kind of a bummer.

If there are cases like this showing up a lot in the ecosystem, can we just verify quick that we aren't being more strict than other type checkers? If not, then these are just true positives (at least as far as the specified type system is concerned) in the ecosystem that we are catching, great. If so, maybe other type checkers are implementing some kind of pragmatic compromise that we should at least consider.

Ah, this turned out to be exactly the issue that @AlexWaygood caught above: I was being too strict and making TypedDicts assignable only to Mapping[str, object], rather than to anything that Mapping[str, object] is assignable to. Alex's patch fixes this.

@oconnor663
Copy link
Contributor Author

It looks like there are a few new false positives on the typing conformance suite file typeddicts_extra_items.py -- places where we're not meant to emit diagnostic, but we now do...I guess this is just because we don't support extra_items at all yet?

Yes I think all of the new errors-that-shouldn't-be-there are cases where extra_items makes more assignments to dict legal. It might not be too hard to add extra_items support as part of this (or even to add a blanket "if you use extra_items we assume all assignments are legal" workaround), but then again since it's a Python 3.15 feature I'm leaning towards just accepting the false positives for a little while for simplicity?

@oconnor663 oconnor663 force-pushed the jack/typedict_assignment branch from b2d6f35 to bc09e93 Compare November 17, 2025 19:31
@oconnor663
Copy link
Contributor Author

There's always a chance that fixing one/both of those will "miraculously" fix the regression

Alas, no. Digging into it.

@oconnor663
Copy link
Contributor Author

I don't have an answer yet, but an example of a specific file that's giving us trouble (which might be infecting a lot of other files, not sure) is pydantic-core/python/pydantic_core/core_schema.py. That file defines a bunch of "*Schema" classes that are all TypedDicts, and it seems like most of those have one or more fields of type CoreSchema, which is actually a large Union of all the "*Schema" classes. So there's a lot of recursion going on.

@AlexWaygood
Copy link
Member

Something you could try would be to add Salsa caching to Type::is_assignable_to. We already cache Type::is_redundant_with; you'd want to add the #[salsa::tracked] attribute to Type::is_assignable_to in the same way that Type::is_redundant_with already has.

The cost of doing this would be an increased memory usage, but that might be worth it to avoid a 30x execution time increase and benchmarks that no longer complete in CI... worth experimenting with, anyway.

@oconnor663 oconnor663 force-pushed the jack/typedict_assignment branch from bc09e93 to df183be Compare November 17, 2025 23:54
Comment on lines 180 to 184
KnownClass::Object.to_instance(db).when_assignable_to(
db,
target_item_field.declared_ty,
inferable,
)
Copy link
Member

Choose a reason for hiding this comment

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

slightly more efficient, and less verbose:

Suggested change
KnownClass::Object.to_instance(db).when_assignable_to(
db,
target_item_field.declared_ty,
inferable,
)
Type::object().when_assignable_to(
db,
target_item_field.declared_ty,
inferable,
)

also, is really correct that we should use when_assignable_to even if relation == TypeRelation::Subtyping? Should it be this instead?

Suggested change
KnownClass::Object.to_instance(db).when_assignable_to(
db,
target_item_field.declared_ty,
inferable,
)
Type::object().has_relation_to_impl(
db,
target_item_field.declared_ty,
inferable,
relation,
relation_visitor,
disjointness_visitor,
)

(no tests fail if I make that change, so it looks like we might be missing some test coverage here either way ;)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

weird: committing the first suggestion resolves the whole comment, even though the second suggestion is pending

Copy link
Contributor Author

@oconnor663 oconnor663 Nov 18, 2025

Choose a reason for hiding this comment

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

What's an example of something I can do in Python that demands the "subtyping" relation, as opposed to the "assignability" relation? (Besides ty_extensions.is_subtype_of I guess.) I get that they're going to be the same for fully-static types, so I know part of the answer is going to involve Any or similar, but I'm afraid I've gotten this far without actually knowing what subtyping per se is for :-D

(Edit: I went ahead and asked this in chat.)

@MichaReiser
Copy link
Member

The way I try to debug those issues is to add a tracing::log to the cycle's recovery function where I log the previous and current value. This way you can see how the query converges (it might be something as simple as that the order of some fields or similar change)

@AlexWaygood
Copy link
Member

AlexWaygood commented Nov 19, 2025

New minimal repro. Seems sensitive to the order of appearance of different types within the union too.

from typing import Union, TypedDict

class Foo1(TypedDict):
    x1: MyUnion

class Foo2(TypedDict):
    x2: MyUnion

class Foo3(Foo2):
    x3: MyUnion

class Foo4(TypedDict):
    x4: MyUnion

MyUnion = Foo1 | Foo2 | Foo3 | Foo4

I can't reproduce a panic on your branch for this snippet, but I can if I add from __future__ import annotations to the top of the file. I'm invoking ty on a file inside the Ruff repo -- possibly you're invoking ty on a file in a slightly different location, which means that it's using our default Python version (3.14, which has deferred annotations by default at runtime) instead of the resolved Python version from Ruff's pyproject.toml file (3.7).

EDIT: ah, and I was explicitly passing --python-version=3.13 from the CLI to override Ruff's pyproject.toml.

@AlexWaygood
Copy link
Member

I tried out this change locally:

Patch
diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs
index 17b5fe7625..109a44e159 100644
--- a/crates/ty_python_semantic/src/types/class.rs
+++ b/crates/ty_python_semantic/src/types/class.rs
@@ -126,16 +126,6 @@ fn try_metaclass_cycle_initial<'db>(
     })
 }
 
-fn fields_cycle_initial<'db>(
-    _db: &'db dyn Db,
-    _id: salsa::Id,
-    _self: ClassLiteral<'db>,
-    _specialization: Option<Specialization<'db>>,
-    _field_policy: CodeGeneratorKind<'db>,
-) -> FxIndexMap<Name, Field<'db>> {
-    FxIndexMap::default()
-}
-
 /// A category of classes with code generation capabilities (with synthesized methods).
 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)]
 pub(crate) enum CodeGeneratorKind<'db> {
@@ -2339,7 +2329,7 @@ impl<'db> ClassLiteral<'db> {
 
                 // Use the alias name if provided, otherwise use the field name
                 let parameter_name =
-                    Name::new(alias.map(|alias| &**alias).unwrap_or(&**field_name));
+                    Name::new(alias.map(|alias| &**alias).unwrap_or(&*field_name));
 
                 let mut parameter = if is_kw_only {
                     Parameter::keyword_only(parameter_name)
@@ -2603,8 +2593,8 @@ impl<'db> ClassLiteral<'db> {
                 )))
             }
             (CodeGeneratorKind::TypedDict, "get") => {
-                let overloads = self
-                    .fields(db, specialization, field_policy)
+                let fields = self.fields(db, specialization, field_policy);
+                let overloads = fields
                     .iter()
                     .flat_map(|(name, field)| {
                         let key_type =
@@ -2834,7 +2824,6 @@ impl<'db> ClassLiteral<'db> {
     /// Returns a list of all annotated attributes defined in this class, or any of its superclasses.
     ///
     /// See [`ClassLiteral::own_fields`] for more details.
-    #[salsa::tracked(returns(ref), cycle_initial=fields_cycle_initial, heap_size=get_size2::GetSize::get_heap_size)]
     pub(crate) fn fields(
         self,
         db: &'db dyn Db,
diff --git a/crates/ty_python_semantic/src/types/diagnostic.rs b/crates/ty_python_semantic/src/types/diagnostic.rs
index ed8b1be1ab..1542da24ac 100644
--- a/crates/ty_python_semantic/src/types/diagnostic.rs
+++ b/crates/ty_python_semantic/src/types/diagnostic.rs
@@ -35,7 +35,7 @@ use ruff_python_ast::parenthesize::parentheses_iterator;
 use ruff_python_ast::{self as ast, AnyNodeRef, Identifier};
 use ruff_python_trivia::CommentRanges;
 use ruff_text_size::{Ranged, TextRange};
-use rustc_hash::FxHashSet;
+use rustc_hash::{FxHashMap, FxHashSet};
 use std::fmt::Formatter;
 
 /// Registers all known type check lints.
@@ -3190,7 +3190,7 @@ pub(crate) fn report_invalid_key_on_typed_dict<'db>(
     typed_dict_ty: Type<'db>,
     full_object_ty: Option<Type<'db>>,
     key_ty: Type<'db>,
-    items: &FxIndexMap<Name, Field<'db>>,
+    items: &FxHashMap<Name, Field<'db>>,
 ) {
     let db = context.db();
     if let Some(builder) = context.report_lint(&INVALID_KEY, key_node) {
diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs
index 85c645d37a..90bbb33ea9 100644
--- a/crates/ty_python_semantic/src/types/infer/builder.rs
+++ b/crates/ty_python_semantic/src/types/infer/builder.rs
@@ -919,9 +919,9 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
                 CodeGeneratorKind::from_class(self.db(), class, None)
             {
                 let specialization = None;
+                let fields = class.fields(self.db(), specialization, field_policy);
 
-                let kw_only_sentinel_fields: Vec<_> = class
-                    .fields(self.db(), specialization, field_policy)
+                let kw_only_sentinel_fields: Vec<_> = fields
                     .iter()
                     .filter_map(|(name, field)| {
                         field.is_kw_only_sentinel(self.db()).then_some(name)
diff --git a/crates/ty_python_semantic/src/types/typed_dict.rs b/crates/ty_python_semantic/src/types/typed_dict.rs
index c093dbef25..8e94a4fb1f 100644
--- a/crates/ty_python_semantic/src/types/typed_dict.rs
+++ b/crates/ty_python_semantic/src/types/typed_dict.rs
@@ -4,6 +4,7 @@ use ruff_db::parsed::parsed_module;
 use ruff_python_ast::Arguments;
 use ruff_python_ast::{self as ast, AnyNodeRef, StmtClassDef, name::Name};
 use ruff_text_size::Ranged;
+use rustc_hash::FxHashMap;
 
 use super::class::{ClassType, CodeGeneratorKind, Field};
 use super::context::InferContext;
@@ -12,10 +13,10 @@ use super::diagnostic::{
     report_missing_typed_dict_key,
 };
 use super::{ApplyTypeMappingVisitor, Type, TypeMapping, visitor};
+use crate::Db;
 use crate::types::constraints::ConstraintSet;
 use crate::types::generics::InferableTypeVars;
 use crate::types::{HasRelationToVisitor, IsDisjointVisitor, TypeContext, TypeRelation};
-use crate::{Db, FxIndexMap};
 
 use ordermap::OrderSet;
 
@@ -56,9 +57,25 @@ impl<'db> TypedDictType<'db> {
         self.defining_class
     }
 
-    pub(crate) fn items(self, db: &'db dyn Db) -> &'db FxIndexMap<Name, Field<'db>> {
-        let (class_literal, specialization) = self.defining_class.class_literal(db);
-        class_literal.fields(db, specialization, CodeGeneratorKind::TypedDict)
+    pub(crate) fn items(self, db: &'db dyn Db) -> &'db FxHashMap<Name, Field<'db>> {
+        #[salsa::tracked(returns(ref), cycle_initial=items_cycle_initial, heap_size=get_size2::GetSize::get_heap_size)]
+        fn items_inner<'db>(db: &'db dyn Db, class: ClassType<'db>) -> FxHashMap<Name, Field<'db>> {
+            let (class_literal, specialization) = class.class_literal(db);
+            class_literal
+                .fields(db, specialization, CodeGeneratorKind::TypedDict)
+                .into_iter()
+                .collect()
+        }
+
+        fn items_cycle_initial<'db>(
+            _db: &'db dyn Db,
+            _id: salsa::Id,
+            _class: ClassType<'db>,
+        ) -> FxHashMap<Name, Field<'db>> {
+            FxHashMap::default()
+        }
+
+        items_inner(db, self.defining_class)
     }

The idea of the change is to make it easier for the cycle to converge by returning an FxHashMap from the cached query rather than an FxIndexMap. An IndexMap cares about the order of the fields (which can make it hard for the query to converge) but a HashMap doesn't. We can't use an order-agnostic map for ClassLiteral::fields() because we care about the order of fields for dataclasses and namedtuples -- it allows us to perform certain useful checks -- so the caching is moved up "one level higher" to TypedDictType::items().

With that patch applied, we still panic on the repro from #21467 (comment) (providing you pass --python-version=3.14) -- the panic just moves to items_inner:

New query stacktrace
error[panic]: Panicked at /Users/alexw/.cargo/git/checkouts/salsa-e6f3bb7c2a062968/a885bb4/src/function/execute.rs:321:21 when checking `/Users/alexw/dev/ruff/foo.py`: `items_inner(Id(4403)): execute: too many cycle iterations`
info: This indicates a bug in ty.
info: If you could open an issue at https://github.com/astral-sh/ty/issues/new?title=%5Bpanic%5D, we'd be very appreciative!
info: Platform: macos aarch64
info: Version: ruff/0.14.5+57 (4160ea580 2025-11-18)
info: Args: ["target/debug/ty", "check", "foo.py", "--python-version=3.14"]
info: run with `RUST_BACKTRACE=1` environment variable to show the full backtrace information
info: query stacktrace:
   0: Type < 'db >::is_redundant_with_(Id(7407))
             at crates/ty_python_semantic/src/types.rs:821
   1: infer_definition_types(Id(1403))
             at crates/ty_python_semantic/src/types/infer.rs:94
             cycle heads: Type < 'db >::is_redundant_with_(Id(7400)) -> iteration = 200
   2: items_inner(Id(4404))
             at crates/ty_python_semantic/src/types/typed_dict.rs:61
   3: Type < 'db >::is_redundant_with_(Id(7400))
             at crates/ty_python_semantic/src/types.rs:821
   4: infer_definition_types(Id(1401))
             at crates/ty_python_semantic/src/types/infer.rs:94
   5: infer_scope_types(Id(1001))
             at crates/ty_python_semantic/src/types/infer.rs:70
   6: check_file_impl(Id(c00))
             at crates/ty_project/src/lib.rs:535

Note that (with or without my patch above), when invoking uv run cargo run --manifest-path=../ruff/Cargo.toml --profile=profiling -p ty check pydantic from the root of my pydantic clone, the panic is due to Type::is_redundant_with failing to converge rather than ClassLiteral::fields() or items_inner(). That also seems to be what we're seeing in the mypy_primer logs:

Pydantic query stacktrace
error[panic]: Panicked at /Users/alexw/.cargo/git/checkouts/salsa-e6f3bb7c2a062968/a885bb4/src/function/execute.rs:321:21 when checking `/Users/alexw/dev/pydantic/pydantic/functional_serializers.py`: `Type < 'db >::is_redundant_with_(Id(92ae0)): execute: too many cycle iterations`
info: This indicates a bug in ty.
info: If you could open an issue at https://github.com/astral-sh/ty/issues/new?title=%5Bpanic%5D, we'd be very appreciative!
info: Platform: macos aarch64
info: Version: ruff/0.14.5+57 (4160ea580 2025-11-18)
info: Args: ["/Users/alexw/dev/ruff/target/profiling/ty", "check", "pydantic"]
info: run with `RUST_BACKTRACE=1` environment variable to show the full backtrace information
info: query stacktrace:
   0: infer_definition_types(Id(221d2))
             at crates/ty_python_semantic/src/types/infer.rs:94
             cycle heads: Type < 'db >::is_redundant_with_(Id(63427)) -> iteration = 200
   1: items_inner(Id(15404))
             at crates/ty_python_semantic/src/types/typed_dict.rs:61
   2: Type < 'db >::is_redundant_with_(Id(92ae9))
             at crates/ty_python_semantic/src/types.rs:821
   3: infer_definition_types(Id(22194))
             at crates/ty_python_semantic/src/types/infer.rs:94
             cycle heads: Type < 'db >::is_redundant_with_(Id(63426)) -> iteration = 200
   4: items_inner(Id(2a40b))
             at crates/ty_python_semantic/src/types/typed_dict.rs:61
   5: Type < 'db >::is_redundant_with_(Id(63427))
             at crates/ty_python_semantic/src/types.rs:821
   6: infer_definition_types(Id(221e5))
             at crates/ty_python_semantic/src/types/infer.rs:94
   7: items_inner(Id(15405))
             at crates/ty_python_semantic/src/types/typed_dict.rs:61
   8: Type < 'db >::is_redundant_with_(Id(63426))
             at crates/ty_python_semantic/src/types.rs:821
   9: infer_deferred_types(Id(4c21))
             at crates/ty_python_semantic/src/types/infer.rs:141
  10: infer_scope_types(Id(3401))
             at crates/ty_python_semantic/src/types/infer.rs:70
  11: check_file_impl(Id(1407))
             at crates/ty_project/src/lib.rs:535

@AlexWaygood
Copy link
Member

This patch gets us passing on both minimal repros so far (#21467 (comment) and #21467 (comment)), and seems probably worth doing on its own merits, since it'll make cycles rarer and avoid us having to engage in a full structual check for many TypedDict assignability checks:

Patch
diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs
index 17b5fe7625..78beca70d1 100644
--- a/crates/ty_python_semantic/src/types/class.rs
+++ b/crates/ty_python_semantic/src/types/class.rs
@@ -566,7 +566,9 @@ impl<'db> ClassType<'db> {
                 },
 
                 // Protocol and Generic are not represented by a ClassType.
-                ClassBase::Protocol | ClassBase::Generic => ConstraintSet::from(false),
+                ClassBase::Protocol | ClassBase::Generic | ClassBase::TypedDict => {
+                    ConstraintSet::from(false)
+                }
 
                 ClassBase::Class(base) => match (base, other) {
                     (ClassType::NonGeneric(base), ClassType::NonGeneric(other)) => {
@@ -589,11 +591,6 @@ impl<'db> ClassType<'db> {
                         ConstraintSet::from(false)
                     }
                 },
-
-                ClassBase::TypedDict => {
-                    // TODO: Implement subclassing and assignability for TypedDicts.
-                    ConstraintSet::from(true)
-                }
             }
         })
     }
diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs
index 85c645d37a..449e770aff 100644
--- a/crates/ty_python_semantic/src/types/infer/builder.rs
+++ b/crates/ty_python_semantic/src/types/infer/builder.rs
@@ -8007,25 +8007,26 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
             // the `try_call` path below.
             // TODO: it should be possible to move these special cases into the `try_call_constructor`
             // path instead, or even remove some entirely once we support overloads fully.
-            let has_special_cased_constructor = matches!(
-                class.known(self.db()),
-                Some(
-                    KnownClass::Bool
-                        | KnownClass::Str
-                        | KnownClass::Type
-                        | KnownClass::Object
-                        | KnownClass::Property
-                        | KnownClass::Super
-                        | KnownClass::TypeAliasType
-                        | KnownClass::Deprecated
-                )
-            ) || (
-                // Constructor calls to `tuple` and subclasses of `tuple` are handled in `Type::Bindings`,
-                // but constructor calls to `tuple[int]`, `tuple[int, ...]`, `tuple[int, *tuple[str, ...]]` (etc.)
-                // are handled by the default constructor-call logic (we synthesize a `__new__` method for them
-                // in `ClassType::own_class_member()`).
-                class.is_known(self.db(), KnownClass::Tuple) && !class.is_generic()
-            );
+            let has_special_cased_constructor =
+                matches!(
+                    class.known(self.db()),
+                    Some(
+                        KnownClass::Bool
+                            | KnownClass::Str
+                            | KnownClass::Type
+                            | KnownClass::Object
+                            | KnownClass::Property
+                            | KnownClass::Super
+                            | KnownClass::TypeAliasType
+                            | KnownClass::Deprecated
+                    )
+                ) || (
+                    // Constructor calls to `tuple` and subclasses of `tuple` are handled in `Type::Bindings`,
+                    // but constructor calls to `tuple[int]`, `tuple[int, ...]`, `tuple[int, *tuple[str, ...]]` (etc.)
+                    // are handled by the default constructor-call logic (we synthesize a `__new__` method for them
+                    // in `ClassType::own_class_member()`).
+                    class.is_known(self.db(), KnownClass::Tuple) && !class.is_generic()
+                ) || CodeGeneratorKind::TypedDict.matches(self.db(), class.class_literal(self.db()).0, class.class_literal(self.db()).1);
 
             // temporary special-casing for all subclasses of `enum.Enum`
             // until we support the functional syntax for creating enum classes
diff --git a/crates/ty_python_semantic/src/types/typed_dict.rs b/crates/ty_python_semantic/src/types/typed_dict.rs
index c093dbef25..bafe798425 100644
--- a/crates/ty_python_semantic/src/types/typed_dict.rs
+++ b/crates/ty_python_semantic/src/types/typed_dict.rs
@@ -90,6 +90,16 @@ impl<'db> TypedDictType<'db> {
         relation_visitor: &HasRelationToVisitor<'db>,
         disjointness_visitor: &IsDisjointVisitor<'db>,
     ) -> ConstraintSet<'db> {
+        // First do a quick nominal check that (if it succeeds) means that we can avoid
+        // materializing the full `TypeDict` schema for either `self` or `target`.
+        // This should be cheaper in many cases, and also helps us avoid some cycles.
+        if self
+            .defining_class
+            .is_subclass_of(db, target.defining_class)
+        {
+            return ConstraintSet::from(true);
+        }
+
         let self_items = self.items(db);
         let target_items = target.items(db);
         // Many rules violations short-circuit with "never", but asking whether one field is

We still panic when checking pydantic, even with that patch applied, however, so @oconnor663 will have to find a new minimal repro if he applies the patch ;)

@oconnor663
Copy link
Contributor Author

I'm invoking ty on a file inside the Ruff repo -- possibly you're invoking ty on a file in a slightly different location, which means that it's using our default Python version (3.14, which has deferred annotations by default at runtime) instead of the resolved Python version from Ruff's pyproject.toml file (3.7).

Yes I should've said I'm just running all of these snippets out of /tmp, so I think everything is ty defaults.

@oconnor663
Copy link
Contributor Author

oconnor663 commented Nov 19, 2025

Here's a new minimized repro (running as a standalone script out of /tmp), which still panics with @AlexWaygood's has_special_cased_constructor patch above:

from typing import TypedDict

class Foo1(TypedDict):
    x1: MyUnion

class Foo2(TypedDict):
    x2: MyUnion

class Foo3(Foo2):
    pass

class Foo4(Foo2):
    x3: MyUnion

class Foo5(TypedDict):
    x5: MyUnion

MyUnion = Foo1 | Foo3 | Foo4 | Foo5
error[panic]: Panicked at /home/jacko/.cargo/git/checkouts/salsa-e6f3bb7c2a062968/a885bb4/src/function/execute.rs:321:21 when checking `/tmp/core_schema.py`: `ClassLiteral < 'db >::fields_(Id(8803)): execute: too many cycle iterations`
info: This indicates a bug in ty.
info: If you could open an issue at https://github.com/astral-sh/ty/issues/new?title=%5Bpanic%5D, we'd be very appreciative!
info: Platform: linux x86_64
info: Version: ruff/0.14.5+62 (a2e803e65 2025-11-19)
info: Args: ["/home/jacko/astral/ruff/target-mold/debug/ty", "check", "core_schema.py"]
info: run with `RUST_BACKTRACE=1` environment variable to show the full backtrace information
info: query stacktrace:
   0: Type < 'db >::is_redundant_with_(Id(8407))
             at crates/ty_python_semantic/src/types.rs:821
   1: infer_definition_types(Id(1403))
             at crates/ty_python_semantic/src/types/infer.rs:94
             cycle heads: Type < 'db >::is_redundant_with_(Id(8400)) -> iteration = 200
   2: ClassLiteral < 'db >::fields_(Id(8800))
             at crates/ty_python_semantic/src/types/class.rs:1370
   3: Type < 'db >::is_redundant_with_(Id(8400))
             at crates/ty_python_semantic/src/types.rs:821
   4: infer_definition_types(Id(1401))
             at crates/ty_python_semantic/src/types/infer.rs:94
   5: infer_scope_types(Id(1001))
             at crates/ty_python_semantic/src/types/infer.rs:70
   6: check_file_impl(Id(c00))
             at crates/ty_project/src/lib.rs:535

It feels "pretty much the same" as the last one. What I'm noticing as I minimize these, is that there are a lot of "paths" I can take on the way down. I'll get a different result if I start ripping things out from the top vs from the bottom vs in the middle. Eventually I rip out something that removes the panic, so I put that thing back and roll the dice again on where to delete more stuff. So the end result is 1) one of many local minima I could wind up at and 2) maximally sensitive to the order of elements within the union. Which means that minor tweaks in how things are evaluated are likely to "fix" one of these minimal examples by coincidence without really fixing the underlying issue.

I'm going to try to follow @carljm and @MichaReiser's advice above and play around with logging this some more, to see if I can get some more intuition about what's going on.

@oconnor663
Copy link
Contributor Author

oconnor663 commented Nov 19, 2025

Making some progress with printouts. (Finally figured out that a "cycle recovery function" is cycle_fn. There are only two such examples in ty?) Here's what the values of .fields() look like right before the crash:

...
Foo5 (iteration #195)
    field x5: [missing] --> Foo5
Foo4 (iteration #195)
    field x2: Foo1|Foo3|Foo4|Foo5 --> Foo1|Foo3|Foo5
    field x4: Foo1|Foo3|Foo5 --> Foo5
Foo3 (iteration #195)
    field x2: Foo1|Foo3|Foo5 --> Foo5
Foo5 (iteration #196)
    field x5: Foo5 --> Foo1|Foo3|Foo5
Foo4 (iteration #196)
    field x2: Foo1|Foo3|Foo5 --> Foo5
    field x4: Foo5 --> Foo1|Foo3|Foo4|Foo5
Foo3 (iteration #196)
    field x2: Foo5 --> Foo1|Foo3|Foo4|Foo5
Foo5 (iteration #197)
    field x5: Foo1|Foo3|Foo5 --> Foo1|Foo3|Foo4|Foo5
Foo4 (iteration #197)
    field x2: Foo5 --> Foo1|Foo3|Foo4|Foo5
Foo3 (iteration #197)
    NO CHANGES!
Foo4 (iteration #198)
    field x4: Foo1|Foo3|Foo4|Foo5 --> Foo1|Foo3|Foo5
Foo3 (iteration #198)
    field x2: Foo1|Foo3|Foo4|Foo5 --> Foo1|Foo3|Foo5
Foo5 (iteration #199)
    field x5: [missing] --> Foo5
Foo4 (iteration #199)
    field x2: Foo1|Foo3|Foo4|Foo5 --> Foo1|Foo3|Foo5
    field x4: Foo1|Foo3|Foo5 --> Foo5
Foo3 (iteration #199)
    field x2: Foo1|Foo3|Foo5 --> Foo5

Notable that the union keeps collapsing down to a single TypedDict type. More staring required...

@carljm
Copy link
Contributor

carljm commented Nov 19, 2025

One thing that we could consider here that might help is to just never consider two named TypedDict types as redundant in a union, unless they are the exact same type. This would lead to less union simplification, but that might be fine, actually. And I suspect it would solve this problem.

@oconnor663
Copy link
Contributor Author

I'll try that. Separately, I've been refining the printout above, and now I see that sometimes my cycle_fn is being called when the previous value and current value are equal. I'm surprised by that, since I thought that was exactly the condition that ends the cycling. Will ask in chat.

@oconnor663
Copy link
Contributor Author

Also Alex mentioned at one point that he hasn't been able to get Protocol versions of examples like these to produce a similar panic. Not yet clear what's different between TypedDict and Protocol in this respect. I haven't looked into it yet.

@oconnor663 oconnor663 force-pushed the jack/typedict_assignment branch 2 times, most recently from fd7ac18 to 17e3bf0 Compare November 19, 2025 21:04
@oconnor663
Copy link
Contributor Author

I think I'm seeing CI runs for later commits getting cancelled in favor of earlier commits, which doesn't make sense to me. Known problem? My fault for force-pushing something? In any case, I've manually restarted https://github.com/astral-sh/ruff/actions/runs/19516380284/job/55870895275, and I think that's the one to watch.

@oconnor663
Copy link
Contributor Author

Ok, everything is passing, except that CodSpeed reports a ~10x slowdown on the Pydantic benchmark. I'm going to try to isolate what parts are costing us the most time, but at the same time I need feedback on whether this is "obviously a performance bug" or "maybe reasonable given how convoluted their unions are"?

@carljm
Copy link
Contributor

carljm commented Nov 19, 2025

The magnitude of the regression is surprising to me. I definitely think we should explore how we can be more efficient here, but given that no other project regresses more than 1%, it's clearly a factor of the size and ubiquity of the typed dicts in pydantic; I wouldn't necessarily be opposed to going ahead and landing this and doing more optimization as a follow-up.

Copy link
Contributor

@carljm carljm left a comment

Choose a reason for hiding this comment

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

Excellent work!

@AlexWaygood AlexWaygood dismissed their stale review November 20, 2025 08:19

Requested changes were made

Copy link
Member

@AlexWaygood AlexWaygood left a comment

Choose a reason for hiding this comment

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

Great work!!

Since Carl already reviewed, I haven't done an exhaustive review of the semantics here vis-a-vis the spec -- just a few notes from me.

I think we should definitely look at improving perf here as a (post-beta?) followup. Aside from anything else, it's frustrating that the benchmarks CI job now takes 5 minutes longer than it used to 😆 But I do agree that we should land this now and move on; there's too much else to do before the beta.

I don't think you have any tests currently for generic typeddicts. It looks like everything works correctly on your branch, but it would be great to test it explicitly; something like this?

from typing import TypedDict
from ty_extensions import static_assert, is_assignable_to, is_subtype_of

class F[T](TypedDict):
    x: T

class G[T](TypedDict):
    x: T

static_assert(is_assignable_to(F, G))

def x[T](a: T) -> T:
    static_assert(is_subtype_of(F[T], G[T]))
    return a

static_assert(is_subtype_of(F[int], G[int]))
static_assert(not is_assignable_to(F[int], G[str]))

Some tests that use the legacy syntax (multiple-inheriting from TypedDict and Generic[T]) would be great as well.

I ran the property tests on this branch and didn't see any issues. Though I don't think we include any TypedDict types in the property tests right now, so that's not a massive surprise 😆. We should probably open a followup issue for that -- again, that should probably be done post-beta.

relation_visitor: &HasRelationToVisitor<'db>,
disjointness_visitor: &IsDisjointVisitor<'db>,
) -> ConstraintSet<'db> {
// First do a quick nominal check that (if it succeeds) means that we can avoid
Copy link
Contributor

Choose a reason for hiding this comment

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

I just talked with Alex about the 10x performance regression on pydantic here, caused by the large union of TypedDicts (many of which are probably not subtypes of each other). When we build that union, we need to perform O(n²) subtyping checks, so it's understandable that we see a huge regression now that we actually do nontrivial work in those checks. Other than the nominal check here (which is probably not super fast, actually), are there any other short circuit paths where we could return false early? The most common case might be something like the following where two "normal" TypedDicts (no extra_items or similar) are simply incompatible because their key names are not compatible:

class A(TypedDict):
    key_a: int

class B(TypedDict):
    key_b: int

Is there something we can do by just looking at the names, without ever looking at the value types?

Copy link
Member

Choose a reason for hiding this comment

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

I tried out something like this, and it passes all tests, but doesn't seem to lead to any speedup sadly:

diff --git a/crates/ty_python_semantic/src/types/typed_dict.rs b/crates/ty_python_semantic/src/types/typed_dict.rs
index bafe798425..6f638c9817 100644
--- a/crates/ty_python_semantic/src/types/typed_dict.rs
+++ b/crates/ty_python_semantic/src/types/typed_dict.rs
@@ -102,6 +102,21 @@ impl<'db> TypedDictType<'db> {
 
         let self_items = self.items(db);
         let target_items = target.items(db);
+
+        // For `self` to be a subtype of `target`,
+        // all `target` fields must be present in `self` unless
+        // the target field specifically has the form `NotRequired[ReadOnly[object]]`.
+        // We therefore do a quick check for missing keys here before doing the full
+        // (expensive) relation checks below.
+        for (name, field) in target_items {
+            if field.is_read_only() && !field.is_required() && field.declared_ty.is_object() {
+                continue;
+            }
+            if !self_items.contains_key(name) {
+                return ConstraintSet::from(false);
+            }
+        }
+
         // Many rules violations short-circuit with "never", but asking whether one field is
         // [relation] to/of another can produce more complicated constraints, and we collect those.
         let mut constraints = ConstraintSet::from(true);

Copy link
Contributor

Choose a reason for hiding this comment

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

Did you verify that this early return actually triggers on the pydantic code? If so, is it possible that the nominal subtyping check above is expensive?

Copy link
Member

Choose a reason for hiding this comment

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

Did you verify that this early return actually triggers on the pydantic code?

No, I didn't

If so, is it possible that the nominal subtyping check above is expensive?

That's certainly possible... even if it is expensive, I think it may still be worth doing to reduce the chance of pathological cycles for recursive typeddicts. But that's just speculation.

Copy link
Member

Choose a reason for hiding this comment

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

I just tried out this patch, which also did not speed things up materially:

diff --git a/crates/ty_python_semantic/src/types/typed_dict.rs b/crates/ty_python_semantic/src/types/typed_dict.rs
index bafe798425..c086eb4c11 100644
--- a/crates/ty_python_semantic/src/types/typed_dict.rs
+++ b/crates/ty_python_semantic/src/types/typed_dict.rs
@@ -90,18 +90,23 @@ impl<'db> TypedDictType<'db> {
         relation_visitor: &HasRelationToVisitor<'db>,
         disjointness_visitor: &IsDisjointVisitor<'db>,
     ) -> ConstraintSet<'db> {
-        // First do a quick nominal check that (if it succeeds) means that we can avoid
-        // materializing the full `TypeDict` schema for either `self` or `target`.
-        // This should be cheaper in many cases, and also helps us avoid some cycles.
-        if self
-            .defining_class
-            .is_subclass_of(db, target.defining_class)
-        {
-            return ConstraintSet::from(true);
-        }
-
         let self_items = self.items(db);
         let target_items = target.items(db);
+
+        // For `self` to be a subtype of `target`,
+        // all `target` fields must be present in `self` unless
+        // the target field specifically has the form `NotRequired[ReadOnly[object]]`.
+        // We therefore do a quick check for missing keys here before doing the full
+        // (expensive) relation checks below.
+        for (name, field) in target_items {
+            if field.is_read_only() && !field.is_required() && field.declared_ty.is_object() {
+                continue;
+            }
+            if !self_items.contains_key(name) {
+                return ConstraintSet::from(false);
+            }
+        }
+
         // Many rules violations short-circuit with "never", but asking whether one field is
         // [relation] to/of another can produce more complicated constraints, and we collect those.
         let mut constraints = ConstraintSet::from(true);

and adding Salsa caching to Type::is_assignable_to also didn't help. The benchmark stays stubbornly in the 19s-20s range for me locally whatever I do.

Copy link
Contributor

@carljm carljm Nov 20, 2025

Choose a reason for hiding this comment

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

Given that the expense in the profile above looks to be inside is_assignable_to (not is_subtype_of or is_redundant_with), it may be worth trying to Salsa cache is_assignable_to again?

I know we tried that on an earlier version of this PR and it didn't fix the even-more-massive blowup then -- but that was solved by caching fields. It seems like the current smaller blowup might be helped by caching is_assignable_to.

Copy link
Member

@AlexWaygood AlexWaygood Nov 20, 2025

Choose a reason for hiding this comment

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

Given that the expense in the profile above looks to be inside is_assignable_to (not is_subtype_of or is_redundant_with), it may be worth trying to Salsa cache is_assignable_to again?

As I said above in #21467 (comment), I tried that earlier today and it didn't yield any speedup

Copy link
Contributor

Choose a reason for hiding this comment

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

Ah sorry, I missed that brief note after the big code diff...

Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe caching is_subclass_of directly?

Copy link
Contributor Author

@oconnor663 oconnor663 Nov 20, 2025

Choose a reason for hiding this comment

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

I'm measuring ~no performance impact from this diff on Pydantic :(

diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs
index fc3b9d4227..93827ae16b 100644
--- a/crates/ty_python_semantic/src/types/class.rs
+++ b/crates/ty_python_semantic/src/types/class.rs
@@ -524,6 +524,7 @@ impl<'db> ClassType<'db> {
     }
 
     /// Return `true` if `other` is present in this class's MRO.
+    #[salsa::tracked]
     pub(super) fn is_subclass_of(self, db: &'db dyn Db, other: ClassType<'db>) -> bool {
         self.when_subclass_of(db, other, InferableTypeVars::None)
             .is_always_satisfied(db)
@@ -1758,6 +1759,7 @@ impl<'db> ClassLiteral<'db> {
     }
 
     /// Return `true` if `other` is present in this class's MRO.
+    #[salsa::tracked]
     pub(super) fn is_subclass_of(
         self,
         db: &'db dyn Db,

I think I've addressed all the non-perf comments, and CI is green except for CodSpeed (which doesn't list any other regressions besides Pydantic), so if there are no objections I'll go ahead and land this as-is and defer the rest of the perf work until after Beta.

@oconnor663 oconnor663 force-pushed the jack/typedict_assignment branch from dd9e1ad to 19933ce Compare November 20, 2025 19:14
class NotRequiredReadOnlyAny(TypedDict):
x: NotRequired[ReadOnly[Any]]

# fmt: off
Copy link
Member

Choose a reason for hiding this comment

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

lol, I sorta prefer Black's formatting 😆

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Black's formatting looks nicer if you're scrolling past the code, but I think this version is better if you're actually trying to read it. (Or more to the point for me, if you're trying to edit it.)

Copy link
Member

@AlexWaygood AlexWaygood Nov 20, 2025

Choose a reason for hiding this comment

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

Editing -- yes. Reading -- I disagree. But I also don't feel strongly; don't feel you need to change this just to keep me happy :-)

@AlexWaygood
Copy link
Member

I think I've addressed all the non-perf comments

Could you possibly add some tests for generic typeddicts (I gave some suggestions in #21467 (review))? But otherwise, yes, I think let's get this in 👍

@oconnor663
Copy link
Contributor Author

Woops, thanks for catching. Added structural assignability to the existing mdtest sections for both class-style and legacy generic typeddicts.

@oconnor663 oconnor663 merged commit eb7c098 into main Nov 20, 2025
40 of 41 checks passed
@oconnor663 oconnor663 deleted the jack/typedict_assignment branch November 20, 2025 21:15
dcreager added a commit that referenced this pull request Nov 24, 2025
…d-typevar

* origin/main: (24 commits)
  [ty] Remove brittle constraint set reveal tests (#21568)
  [`ruff`] Catch more dummy variable uses (`RUF052`) (#19799)
  [ty] Use the same snapshot handling as other tests (#21564)
  [ty] suppress autocomplete suggestions during variable binding (#21549)
  Set severity for non-rule diagnostics (#21559)
  [ty] Add `with_type` convenience to display code (#21563)
  [ty] Implement docstring rendering to markdown (#21550)
  [ty] Reduce indentation of `TypeInferenceBuilder::infer_attribute_load` (#21560)
  Bump 0.14.6 (#21558)
  [ty] Improve debug messages when imports fail (#21555)
  [ty] Add support for relative import completions
  [ty] Refactor detection of import statements for completions
  [ty] Use dedicated collector for completions
  [ty] Attach subdiagnostics to `unresolved-import` errors for relative imports as well as absolute imports (#21554)
  [ty] support PEP 613 type aliases (#21394)
  [ty] More low-hanging fruit for inlay hint goto-definition (#21548)
  [ty] implement `TypedDict` structural assignment (#21467)
  [ty] Add more random TypeDetails and tests (#21546)
  [ty] Add goto for `Unknown` when it appears in an inlay hint (#21545)
  [ty] Add type definitions for `Type::SpecialForm`s (#21544)
  ...
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.

Implement structural assignability for TypedDicts

6 participants