Skip to content

Comments

[ty] Improve protocol member type checking and relation handling#18847

Merged
AlexWaygood merged 14 commits intoastral-sh:mainfrom
mtshiba:protocol-member-checks
Jun 29, 2025
Merged

[ty] Improve protocol member type checking and relation handling#18847
AlexWaygood merged 14 commits intoastral-sh:mainfrom
mtshiba:protocol-member-checks

Conversation

@mtshiba
Copy link
Collaborator

@mtshiba mtshiba commented Jun 21, 2025

Summary

This PR adds handling for checking the types of protocol members to Type::satisfies_protocol and Type::is_disjoint_from.
It only adds checks for simple members, not methods or properties for now.

The reason for adding this change is that Protocol member checks are required to implement advanced attribute narrowing (see astral-sh/ty#643). I understand that there are some issues with the current Protocol support that need to be resolved, and if adding this PR would get in the way of solving those issues, I would like to wait until those issues are resolved.

Test Plan

Protocol tests are added.

@github-actions
Copy link
Contributor

github-actions bot commented Jun 21, 2025

mypy_primer results

Changes were detected when running on open source projects
jinja (https://github.com/pallets/jinja)
- TOTAL MEMORY USAGE: ~106MB
+ TOTAL MEMORY USAGE: ~97MB

werkzeug (https://github.com/pallets/werkzeug)
- error[non-subscriptable] src/werkzeug/utils.py:91:13: Cannot subscript object of type `object` with no `__getitem__` method
+ error[non-subscriptable] src/werkzeug/utils.py:91:13: Cannot subscript object of type `property` with no `__getitem__` method
- error[non-subscriptable] src/werkzeug/utils.py:118:17: Cannot subscript object of type `object` with no `__getitem__` method
+ error[non-subscriptable] src/werkzeug/utils.py:118:17: Cannot subscript object of type `property` with no `__getitem__` method

black (https://github.com/psf/black)
+ warning[possibly-unbound-attribute] src/blackd/__init__.py:104:12: Attribute `get` on type `Unknown | under_cached_property[Unknown]` is possibly unbound
+ warning[possibly-unbound-attribute] src/blackd/__init__.py:110:12: Attribute `get` on type `Unknown | under_cached_property[Unknown]` is possibly unbound
+ error[invalid-argument-type] src/blackd/__init__.py:113:31: Argument to function `parse_mode` is incorrect: Expected `MultiMapping[str]`, found `Unknown | under_cached_property[Unknown]`
+ warning[possibly-unbound-attribute] src/blackd/__init__.py:116:27: Attribute `read` on type `Unknown | under_cached_property[Unknown]` is possibly unbound
+ warning[possibly-unbound-attribute] src/blackd/__init__.py:145:26: Attribute `get` on type `Unknown | under_cached_property[Unknown]` is possibly unbound
+ error[unsupported-operator] src/blackd/middlewares.py:15:39: Operator `in` is not supported for types `str` and `under_cached_property[Unknown]`, in comparing `Literal["Access-Control-Request-Method"]` with `Unknown | under_cached_property[Unknown]`
+ warning[possibly-unbound-attribute] src/blackd/middlewares.py:21:18: Attribute `get` on type `Unknown | under_cached_property[Unknown]` is possibly unbound
- Found 66 diagnostics
+ Found 73 diagnostics

trio (https://github.com/python-trio/trio)
+ error[invalid-assignment] src/trio/_util.py:221:17: Invalid assignment to data descriptor attribute `__name__` on type `<Protocol with members '__name__'>` with custom `__set__` method
+ error[invalid-assignment] src/trio/_util.py:223:21: Object of type `str` is not assignable to attribute `__qualname__` on type `<Protocol with members '__name__'> & <Protocol with members '__qualname__'>`
- Found 888 diagnostics
+ Found 890 diagnostics

rich (https://github.com/Textualize/rich)
+ error[invalid-argument-type] rich/layout.py:113:46: Argument to function `ratio_resolve` is incorrect: Expected `Sequence[Edge]`, found `Sequence[Layout]`
+ error[invalid-argument-type] rich/layout.py:133:48: Argument to function `ratio_resolve` is incorrect: Expected `Sequence[Edge]`, found `Sequence[Layout]`
- Found 327 diagnostics
+ Found 329 diagnostics

hydra-zen (https://github.com/mit-ll-responsible-ai/hydra-zen)
- error[invalid-return-type] src/hydra_zen/wrapper/_implementations.py:932:24: Return type does not match returned value: expected `DataClass_`, found `(((...) -> Any) & type & ~type[HydraConf]) | (DataClass_ & type & ~type[HydraConf])`
+ error[invalid-return-type] src/hydra_zen/wrapper/_implementations.py:932:24: Return type does not match returned value: expected `DataClass_ | ListConfig | DictConfig`, found `(((...) -> Any) & type & ~type[HydraConf]) | (DataClass_ & type & ~type[HydraConf]) | (ListConfig & type & ~type[HydraConf]) | (DictConfig & type & ~type[HydraConf])`
- error[invalid-return-type] src/hydra_zen/wrapper/_implementations.py:941:16: Return type does not match returned value: expected `DataClass_`, found `(((...) -> Any) & ~type) | (DataClass_ & ~type) | list[Any] | dict[Any, Any]`
+ error[invalid-return-type] src/hydra_zen/wrapper/_implementations.py:941:16: Return type does not match returned value: expected `DataClass_ | ListConfig | DictConfig`, found `(((...) -> Any) & ~type) | (DataClass_ & ~type) | list[Any] | dict[Any, Any] | (ListConfig & ~type) | (DictConfig & ~type)`
- error[invalid-parameter-default] src/hydra_zen/wrapper/_implementations.py:1479:9: Default value of type `def default_to_config(target: ((...) -> Any) | DataClass_ | list[Any] | dict[Any, Any], CustomBuildsFn: @Todo(unsupported type[X] special form) = <class 'DefaultBuilds'>, **kw: Any) -> DataClass_` is not assignable to annotated parameter type `(F, /) -> @Todo(Support for `typing.TypeAlias`)`
+ error[invalid-parameter-default] src/hydra_zen/wrapper/_implementations.py:1479:9: Default value of type `def default_to_config(target: ((...) -> Any) | DataClass_ | list[Any] | dict[Any, Any] | ListConfig | DictConfig, CustomBuildsFn: @Todo(unsupported type[X] special form) = <class 'DefaultBuilds'>, **kw: Any) -> DataClass_ | ListConfig | DictConfig` is not assignable to annotated parameter type `(F, /) -> @Todo(Support for `typing.TypeAlias`)`
- warning[unused-ignore-comment] tests/annotations/declarations.py:462:23: Unused blanket `type: ignore` directive
+ error[invalid-assignment] tests/annotations/declarations.py:461:5: Object of type `partial[Unknown]` is not assignable to `Partial[int]`
- warning[unused-ignore-comment] tests/annotations/declarations.py:473:42: Unused blanket `type: ignore` directive
+ error[invalid-assignment] tests/annotations/declarations.py:471:5: Object of type `partial[Unknown]` is not assignable to `Partial[int]`
+ error[invalid-assignment] tests/annotations/declarations.py:472:5: Object of type `partial[Unknown]` is not assignable to `Partial[bool]`
- Found 597 diagnostics
+ Found 598 diagnostics

strawberry (https://github.com/strawberry-graphql/strawberry)
+ error[invalid-argument-type] strawberry/cli/commands/export_schema.py:32:32: Argument to function `print_schema` is incorrect: Expected `BaseSchema`, found `Schema`
+ error[invalid-argument-type] strawberry/cli/debug_server.py:25:33: Argument to bound method `__init__` is incorrect: Expected `BaseSchema`, found `Schema`
+ error[invalid-argument-type] strawberry/schema_codegen/__init__.py:199:34: Argument to function `_get_directives` is incorrect: Expected `HasDirectives`, found `FieldDefinitionNode | InputValueDefinitionNode`
+ error[invalid-argument-type] strawberry/schema_codegen/__init__.py:387:34: Argument to function `_get_directives` is incorrect: Expected `HasDirectives`, found `ObjectTypeDefinitionNode | ObjectTypeExtensionNode | InterfaceTypeDefinitionNode | InputObjectTypeDefinitionNode`
- Found 362 diagnostics
+ Found 366 diagnostics

mongo-python-driver (https://github.com/mongodb/mongo-python-driver)
+ error[invalid-assignment] pymongo/ssl_support.py:100:13: Object of type `bool` is not assignable to attribute `check_ocsp_endpoint` on type `SSLContext | (SSLContext & <Protocol with members 'check_ocsp_endpoint'>)`
- Found 455 diagnostics
+ Found 456 diagnostics

mkosi (https://github.com/systemd/mkosi)
- TOTAL MEMORY USAGE: ~129MB
+ TOTAL MEMORY USAGE: ~117MB
-     memo fields = ~106MB
+     memo fields = ~97MB

aiohttp-devtools (https://github.com/aio-libs/aiohttp-devtools)
+ warning[possibly-unbound-attribute] aiohttp_devtools/runserver/log_handlers.py:35:75: Attribute `startswith` on type `Unknown | under_cached_property[Unknown]` is possibly unbound
+ warning[possibly-unbound-attribute] aiohttp_devtools/runserver/log_handlers.py:35:99: Attribute `endswith` on type `Unknown | under_cached_property[Unknown]` is possibly unbound
+ error[no-matching-overload] aiohttp_devtools/runserver/log_handlers.py:60:32: No overload of bound method `__init__` matches arguments
+ warning[possibly-unbound-attribute] aiohttp_devtools/runserver/serve.py:76:20: Attribute `get` on type `Unknown | under_cached_property[Unknown]` is possibly unbound
+ warning[possibly-unbound-attribute] aiohttp_devtools/runserver/serve.py:84:24: Attribute `startswith` on type `Unknown | under_cached_property[Unknown]` is possibly unbound
+ error[non-subscriptable] aiohttp_devtools/runserver/serve.py:365:35: Cannot subscript object of type `under_cached_property[Unknown]` with no `__getitem__` method
+ error[non-subscriptable] aiohttp_devtools/runserver/serve.py:375:17: Cannot subscript object of type `under_cached_property[Unknown]` with no `__getitem__` method
+ error[non-subscriptable] aiohttp_devtools/runserver/serve.py:381:25: Cannot subscript object of type `under_cached_property[Unknown]` with no `__getitem__` method
+ error[non-subscriptable] aiohttp_devtools/runserver/serve.py:442:17: Cannot subscript object of type `under_cached_property[Unknown]` with no `__getitem__` method
- Found 50 diagnostics
+ Found 59 diagnostics

vision (https://github.com/pytorch/vision)
-     memo fields = ~304MB
+     memo fields = ~276MB

paasta (https://github.com/yelp/paasta)
-     memo fields = ~156MB
+     memo fields = ~171MB

colour (https://github.com/colour-science/colour)
+ error[invalid-assignment] colour/utilities/array.py:892:13: Object of type `@Todo(unsupported type[X] special form)` is not assignable to attribute `DTYPE_INT_DEFAULT` on type `ModuleType & <Protocol with members 'DTYPE_INT_DEFAULT'>`
+ error[invalid-assignment] colour/utilities/array.py:942:13: Object of type `@Todo(unsupported type[X] special form)` is not assignable to attribute `DTYPE_FLOAT_DEFAULT` on type `ModuleType & <Protocol with members 'DTYPE_FLOAT_DEFAULT'>`
- Found 498 diagnostics
+ Found 500 diagnostics

altair (https://github.com/vega/altair)
- error[invalid-argument-type] altair/utils/data.py:362:35: Argument to function `sanitize_geo_interface` is incorrect: Expected `MutableMapping[Any, Any]`, found `Series[Unknown] | @Todo(map_with_boundness: intersections with negative contributions)`
- Found 1277 diagnostics
+ Found 1276 diagnostics

freqtrade (https://github.com/freqtrade/freqtrade)
-     memo fields = ~304MB
+     memo fields = ~276MB

discord.py (https://github.com/Rapptz/discord.py)
+ error[invalid-argument-type] discord/asset.py:174:39: Argument to bound method `__init__` is incorrect: Expected `str | None`, found `str | None | Any | under_cached_property[Unknown]`
+ error[no-matching-overload] discord/asset.py:419:19: No overload of function `splitext` matches arguments
+ error[no-matching-overload] discord/asset.py:504:19: No overload of function `splitext` matches arguments
- warning[unused-ignore-comment] discord/embeds.py:376:58: Unused blanket `type: ignore` directive
- warning[unused-ignore-comment] discord/embeds.py:432:62: Unused blanket `type: ignore` directive
- warning[unused-ignore-comment] discord/embeds.py:475:66: Unused blanket `type: ignore` directive
- warning[unused-ignore-comment] discord/embeds.py:518:62: Unused blanket `type: ignore` directive
- warning[unused-ignore-comment] discord/embeds.py:529:60: Unused blanket `type: ignore` directive
- warning[unused-ignore-comment] discord/embeds.py:540:58: Unused blanket `type: ignore` directive
+ error[non-subscriptable] discord/http.py:112:12: Cannot subscript object of type `under_cached_property[Unknown]` with no `__getitem__` method
+ warning[possibly-unbound-attribute] discord/http.py:392:26: Attribute `get` on type `Unknown | under_cached_property[Unknown]` is possibly unbound
+ warning[possibly-unbound-attribute] discord/http.py:395:38: Attribute `get` on type `Unknown | under_cached_property[Unknown]` is possibly unbound
+ warning[possibly-unbound-attribute] discord/http.py:397:34: Attribute `get` on type `Unknown | under_cached_property[Unknown]` is possibly unbound
+ warning[possibly-unbound-attribute] discord/http.py:400:23: Attribute `get` on type `Unknown | under_cached_property[Unknown]` is possibly unbound
+ error[non-subscriptable] discord/http.py:404:59: Cannot subscript object of type `under_cached_property[Unknown]` with no `__getitem__` method
+ error[non-subscriptable] discord/utils.py:893:20: Cannot subscript object of type `under_cached_property[Unknown]` with no `__getitem__` method
+ warning[possibly-unbound-attribute] discord/utils.py:894:24: Attribute `get` on type `Unknown | under_cached_property[Unknown]` is possibly unbound
- Found 549 diagnostics
+ Found 554 diagnostics

aiohttp (https://github.com/aio-libs/aiohttp)
+ warning[possibly-unbound-attribute] aiohttp/client_middleware_digest_auth.py:397:23: Attribute `get` on type `Unknown | under_cached_property[Unknown]` is possibly unbound
+ warning[possibly-unbound-attribute] aiohttp/client_middleware_digest_auth.py:426:18: Attribute `origin` on type `Unknown | under_cached_property[Unknown]` is possibly unbound
+ error[invalid-argument-type] aiohttp/client_reqrep.py:961:43: Argument to function `__new__` is incorrect: Expected `str`, found `(Unknown & ~AlwaysFalsy) | (under_cached_property[Unknown] & ~AlwaysFalsy) | Literal[""]`
+ error[invalid-argument-type] aiohttp/client_reqrep.py:961:59: Argument to function `__new__` is incorrect: Expected `str`, found `(Unknown & ~AlwaysFalsy) | (under_cached_property[Unknown] & ~AlwaysFalsy) | Literal[""]`
+ warning[possibly-unbound-attribute] aiohttp/connector.py:1393:12: Attribute `endswith` on type `(Unknown & ~None) | under_cached_property[Unknown]` is possibly unbound
+ warning[possibly-unbound-attribute] aiohttp/connector.py:1394:20: Attribute `rstrip` on type `(Unknown & ~None) | under_cached_property[Unknown]` is possibly unbound
+ warning[possibly-unbound-attribute] aiohttp/connector.py:1415:17: Attribute `rstrip` on type `(Unknown & ~AlwaysFalsy) | (str & ~AlwaysFalsy) | (Unknown & ~None) | under_cached_property[Unknown] | @Todo(map_with_boundness: intersections with negative contributions)` is possibly unbound
+ error[invalid-argument-type] aiohttp/cookiejar.py:237:47: Argument to function `is_ip_address` is incorrect: Expected `str | None`, found `Unknown | under_cached_property[Unknown]`
+ warning[possibly-unbound-attribute] aiohttp/cookiejar.py:276:24: Attribute `startswith` on type `Unknown | under_cached_property[Unknown]` is possibly unbound
+ error[non-subscriptable] aiohttp/cookiejar.py:280:34: Cannot subscript object of type `under_cached_property[Unknown]` with no `__getitem__` method
+ warning[possibly-unbound-attribute] aiohttp/cookiejar.py:280:43: Attribute `rfind` on type `Unknown | under_cached_property[Unknown]` is possibly unbound
+ error[invalid-argument-type] aiohttp/cookiejar.py:350:26: Argument to function `is_ip_address` is incorrect: Expected `str | None`, found `(Unknown & ~AlwaysFalsy) | (under_cached_property[Unknown] & ~AlwaysFalsy) | Literal[""]`
+ warning[possibly-unbound-attribute] aiohttp/cookiejar.py:361:38: Attribute `split` on type `Unknown | under_cached_property[Unknown]` is possibly unbound
+ error[invalid-argument-type] aiohttp/cookiejar.py:365:24: Argument to function `len` is incorrect: Expected `Sized`, found `Unknown | under_cached_property[Unknown]`
+ error[invalid-argument-type] aiohttp/helpers.py:199:43: Argument to function `__new__` is incorrect: Expected `str`, found `(Unknown & ~AlwaysFalsy) | (under_cached_property[Unknown] & ~AlwaysFalsy) | Literal[""]`
+ error[invalid-argument-type] aiohttp/helpers.py:199:59: Argument to function `__new__` is incorrect: Expected `str`, found `(Unknown & ~AlwaysFalsy) | (under_cached_property[Unknown] & ~AlwaysFalsy) | Literal[""]`
+ error[invalid-argument-type] aiohttp/helpers.py:310:46: Argument to function `proxy_bypass` is incorrect: Expected `str`, found `(Unknown & ~None) | under_cached_property[Unknown]`
+ error[call-non-callable] aiohttp/helpers.py:315:22: Method `__getitem__` of type `bound method dict[str, ProxyInfo].__getitem__(key: str, /) -> ProxyInfo` is not callable on object of type `dict[str, ProxyInfo]`
+ warning[possibly-unbound-attribute] aiohttp/web_app.py:394:12: Attribute `get` on type `Unknown | under_cached_property[Unknown]` is possibly unbound
+ warning[possibly-unbound-attribute] aiohttp/web_fileresponse.py:206:31: Attribute `timestamp` on type `(Unknown & ~None) | under_cached_property[Unknown]` is possibly unbound
+ warning[possibly-unbound-attribute] aiohttp/web_fileresponse.py:219:32: Attribute `timestamp` on type `(Unknown & ~None) | under_cached_property[Unknown]` is possibly unbound
+ warning[possibly-unbound-attribute] aiohttp/web_fileresponse.py:255:27: Attribute `get` on type `Unknown | under_cached_property[Unknown]` is possibly unbound
+ warning[possibly-unbound-attribute] aiohttp/web_fileresponse.py:309:67: Attribute `timestamp` on type `(Unknown & ~None) | under_cached_property[Unknown]` is possibly unbound
+ warning[possibly-unbound-attribute] aiohttp/web_fileresponse.py:319:25: Attribute `start` on type `Unknown | under_cached_property[Unknown]` is possibly unbound
+ warning[possibly-unbound-attribute] aiohttp/web_fileresponse.py:320:38: Attribute `stop` on type `Unknown | under_cached_property[Unknown]` is possibly unbound
+ warning[possibly-unbound-attribute] aiohttp/web_log.py:125:16: Attribute `get` on type `Unknown | under_cached_property[Unknown]` is possibly unbound
+ error[invalid-return-type] aiohttp/web_log.py:137:16: Return type does not match returned value: expected `str`, found `(Unknown & ~None) | under_cached_property[Unknown] | Literal["-"]`
+ warning[possibly-unbound-attribute] aiohttp/web_log.py:155:13: Attribute `major` on type `Unknown | under_cached_property[Unknown]` is possibly unbound
+ warning[possibly-unbound-attribute] aiohttp/web_log.py:156:13: Attribute `minor` on type `Unknown | under_cached_property[Unknown]` is possibly unbound
+ warning[possibly-unbound-attribute] aiohttp/web_middlewares.py:84:23: Attribute `route` on type `Unknown | under_cached_property[Unknown]` is possibly unbound
+ error[unsupported-operator] aiohttp/web_middlewares.py:86:16: Operator `in` is not supported for types `str` and `under_cached_property[Unknown]`, in comparing `Literal["?"]` with `Unknown | under_cached_property[Unknown]`
+ warning[possibly-unbound-attribute] aiohttp/web_middlewares.py:87:31: Attribute `split` on type `Unknown | under_cached_property[Unknown]` is possibly unbound
+ error[no-matching-overload] aiohttp/web_middlewares.py:94:39: No overload of function `sub` matches arguments
+ warning[possibly-unbound-attribute] aiohttp/web_middlewares.py:95:37: Attribute `endswith` on type `Unknown | under_cached_property[Unknown]` is possibly unbound
+ error[unsupported-operator] aiohttp/web_middlewares.py:96:39: Operator `+` is unsupported between objects of type `Unknown | under_cached_property[Unknown]` and `Literal["/"]`
+ warning[possibly-unbound-attribute] aiohttp/web_middlewares.py:97:33: Attribute `endswith` on type `Unknown | under_cached_property[Unknown]` is possibly unbound
+ error[non-subscriptable] aiohttp/web_middlewares.py:98:39: Cannot subscript object of type `under_cached_property[Unknown]` with no `__getitem__` method
+ error[unsupported-operator] aiohttp/web_middlewares.py:100:58: Operator `+` is unsupported between objects of type `Unknown | under_cached_property[Unknown]` and `Literal["/"]`
+ warning[possibly-unbound-attribute] aiohttp/web_middlewares.py:101:51: Attribute `endswith` on type `Unknown | under_cached_property[Unknown]` is possibly unbound
+ error[no-matching-overload] aiohttp/web_middlewares.py:102:34: No overload of function `sub` matches arguments
+ warning[possibly-unbound-attribute] aiohttp/web_middlewares.py:119:16: Attribute `current_app` on type `Unknown | under_cached_property[Unknown]` is possibly unbound
+ error[invalid-assignment] aiohttp/web_middlewares.py:120:9: Object of type `Application` is not assignable to attribute `current_app` on type `Unknown | under_cached_property[Unknown]`
+ warning[possibly-unbound-attribute] aiohttp/web_protocol.py:791:31: Attribute `get` on type `Unknown | under_cached_property[Unknown]` is possibly unbound
+ warning[possibly-unbound-attribute] aiohttp/web_response.py:348:27: Attribute `get` on type `Unknown | under_cached_property[Unknown]` is possibly unbound
+ error[invalid-argument-type] aiohttp/web_response.py:689:45: Argument to function `should_remove_content_length` is incorrect: Expected `str`, found `Unknown | under_cached_property[Unknown]`
+ warning[possibly-unbound-attribute] aiohttp/web_urldispatcher.py:321:14: Attribute `get` on type `Unknown | under_cached_property[Unknown]` is possibly unbound
+ warning[possibly-unbound-attribute] aiohttp/web_urldispatcher.py:366:39: Attribute `path_safe` on type `Unknown | under_cached_property[Unknown]` is possibly unbound
+ warning[possibly-unbound-attribute] aiohttp/web_urldispatcher.py:625:16: Attribute `path_safe` on type `Unknown | under_cached_property[Unknown]` is possibly unbound
+ error[non-subscriptable] aiohttp/web_urldispatcher.py:644:19: Cannot subscript object of type `under_cached_property[Unknown]` with no `__getitem__` method
+ warning[possibly-unbound-attribute] aiohttp/web_urldispatcher.py:814:55: Attribute `split` on type `(Unknown & ~None) | under_cached_property[Unknown]` is possibly unbound
+ error[invalid-return-type] aiohttp/web_urldispatcher.py:817:20: Return type does not match returned value: expected `str`, found `(Unknown & ~None) | under_cached_property[Unknown]`
+ warning[possibly-unbound-attribute] aiohttp/web_urldispatcher.py:821:16: Attribute `get` on type `Unknown | under_cached_property[Unknown]` is possibly unbound
+ warning[possibly-unbound-attribute] aiohttp/web_urldispatcher.py:1014:20: Attribute `path_safe` on type `Unknown | under_cached_property[Unknown]` is possibly unbound
+ error[invalid-argument-type] aiohttp/web_urldispatcher.py:1042:56: Argument to bound method `__init__` is incorrect: Expected `str`, found `Unknown | under_cached_property[Unknown]`
+ error[invalid-return-type] aiohttp/web_urldispatcher.py:1260:12: Return type does not match returned value: expected `str`, found `Unknown | under_cached_property[Unknown]`
+ warning[possibly-unbound-attribute] aiohttp/web_ws.py:229:27: Attribute `get` on type `Unknown | under_cached_property[Unknown]` is possibly unbound
+ warning[possibly-unbound-attribute] aiohttp/web_ws.py:234:26: Attribute `get` on type `Unknown | under_cached_property[Unknown]` is possibly unbound
+ warning[possibly-unbound-attribute] aiohttp/web_ws.py:237:29: Attribute `get` on type `Unknown | under_cached_property[Unknown]` is possibly unbound
+ warning[possibly-unbound-attribute] aiohttp/web_ws.py:240:21: Attribute `get` on type `Unknown | under_cached_property[Unknown]` is possibly unbound
+ error[unsupported-operator] aiohttp/web_ws.py:246:12: Operator `in` is not supported for types `istr` and `under_cached_property[Unknown]`, in comparing `istr` with `Unknown | under_cached_property[Unknown]`
+ error[non-subscriptable] aiohttp/web_ws.py:249:30: Cannot subscript object of type `under_cached_property[Unknown]` with no `__getitem__` method
+ warning[possibly-unbound-attribute] aiohttp/web_ws.py:266:19: Attribute `get` on type `Unknown | under_cached_property[Unknown]` is possibly unbound
+ warning[possibly-unbound-attribute] aiohttp/web_ws.py:271:15: Attribute `get` on type `Unknown | under_cached_property[Unknown]` is possibly unbound
+ warning[possibly-unbound-attribute] aiohttp/web_ws.py:292:26: Attribute `get` on type `Unknown | under_cached_property[Unknown]` is possibly unbound
- Found 133 diagnostics
+ Found 197 diagnostics

dd-trace-py (https://github.com/DataDog/dd-trace-py)
+ error[invalid-assignment] ddtrace/appsec/_iast/_ast/visitor.py:669:17: Object of type `Load` is not assignable to attribute `ctx` on type `expr & <Protocol with members 'ctx'>`
- Found 6839 diagnostics
+ Found 6840 diagnostics

sympy (https://github.com/sympy/sympy)
-     memo fields = ~1399MB
+     memo fields = ~1538MB

@mtshiba mtshiba force-pushed the protocol-member-checks branch from 6f64b94 to fd040c4 Compare June 21, 2025 04:07
@AlexWaygood AlexWaygood added the ty Multi-file analysis & type inference label Jun 21, 2025
@codspeed-hq
Copy link

codspeed-hq bot commented Jun 23, 2025

CodSpeed Instrumentation Performance Report

Merging #18847 will not alter performance

Comparing mtshiba:protocol-member-checks (0beafec) with main (9218bf7)

Summary

✅ 39 untouched benchmarks

@codspeed-hq
Copy link

codspeed-hq bot commented Jun 23, 2025

CodSpeed WallTime Performance Report

Merging #18847 will not alter performance

Comparing mtshiba:protocol-member-checks (0beafec) with main (9218bf7)

Summary

✅ 8 untouched benchmarks

@mtshiba mtshiba marked this pull request as ready for review June 23, 2025 14:16
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.

Thanks! This looks okay overall, but I think it would be nice to implement invariance of mutable attribute members; it isn't really correct to treat them covariantly.

You could also consider adding a ProtocolMemberKind enum like I did in #18659? I think we'll need some way of distinguishing between method members, property members and everything else anyway, and your PR already starts to add that distinction

@AlexWaygood
Copy link
Member

Sorry for the delayed review!!

@AlexWaygood
Copy link
Member

The reason for adding this change is that Protocol member checks are required to implement advanced attribute narrowing (see astral-sh/ty#643).

I'm curious how you plan to use protocols to implement that feature. For something like this:

class Foo:
    x: str

def f(foo: Foo):
    if foo.x == "bar":
        ...

It would be incorrect to narrow the type of foo to something like Foo & SynthesizedProtocol[{"x": Literal["bar"]}] there. The reason is that an instance of Foo could have an x attribute that's a str subclass which compares equal to "bar", but for an object to inhabit Literal["bar"] its class must be exactly str (not a subclass). So you can only narrow the type there if you know that the type prior to narrowing was a subtype of LiteralString, since all inhabitants of LiteralString have to be instances of exactly str (not a subclass of str).

@mtshiba
Copy link
Collaborator Author

mtshiba commented Jun 26, 2025

I'm curious how you plan to use protocols to implement that feature. For something like this:

class Foo:
    x: str

def f(foo: Foo):
    if foo.x == "bar":
        ...

It would be incorrect to narrow the type of foo to something like Foo & SynthesizedProtocol[{"x": Literal["bar"]}] there. The reason is that an instance of Foo could have an x attribute that's a str subclass which compares equal to "bar", but for an object to inhabit Literal["bar"] its class must be exactly str (not a subclass). So you can only narrow the type there if you know that the type prior to narrowing was a subtype of LiteralString, since all inhabitants of LiteralString have to be instances of exactly str (not a subclass of str).

Yeah, you mean, for example, if such a "malicious" subclass is used, narrowing is unsound?

class BadStr(str):
    def __eq__(self, other):
        return True

class Foo:
    tag: str = BadStr("Foo")
    value: int

class Bar:
    tag: str = BadStr("Bar")
    value: str

def _(x: Foo | Bar):
    if x.tag == "Foo":
        reveal_type(x.tag)  # str, not Literal["Foo"]
        reveal_type(x.value)  # str | int, not int

The determination that x.tag == "Foo" does not immediately imply x.tag: Literal["Foo"] (it does only if the LHS type is single-valued) is already implemented. So we can use it to assume x: Protocol[{"tag": Literal["Foo"]}] only if the constraint x.tag: Literal["Foo"] can be assumed.

@AlexWaygood
Copy link
Member

Yeah, you mean, for example, if such a "malicious" subclass is used, narrowing is unsound?

Exactly, yes! Okay, it sounds like you're already aware of the issue, which is great 👍

@mtshiba mtshiba force-pushed the protocol-member-checks branch from f6e81f0 to b93f06a Compare June 27, 2025 14:41
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.

I made one inline comment here, but I think it's fine if this is merged with TODOs. I'd like @AlexWaygood to make the call on merging this, but let me know if there are any other parts of it you'd like me to review.

@AlexWaygood AlexWaygood force-pushed the protocol-member-checks branch from 96d49d7 to dfcf0e0 Compare June 27, 2025 21:21
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.

Thanks, this looks great to me! I pushed a few small changes.

Would you be able to take a quick look through the mypy_primer diff and see if the new diagnostics are all expected? Once that's done I'm happy to land this -- thank you!

@mtshiba
Copy link
Collaborator Author

mtshiba commented Jun 28, 2025

I found that the current protocol member check has a problem in the following case.

def f(x: int | LiteralString):
    if hasattr(x, "capitalize"):
        reveal_type(x)  # should be: (int & <Protocol with members 'capitalize'>) | LiteralString
    else:
        reveal_type(x)  # should be: int & ~<Protocol with members 'capitalize'>

It is inferred like this.

info[revealed-type]: Revealed type
 --> protocol_check.py:5:21
  |
3 | def f(x: int | LiteralString):
4 |     if hasattr(x, "capitalize"):
5 |         reveal_type(x)
  |                     ^ `int & <Protocol with members 'capitalize'>`
6 |     else:
7 |         reveal_type(x)
  |

info[revealed-type]: Revealed type
 --> protocol_check.py:7:21
  |
5 |         reveal_type(x)
6 |     else:
7 |         reveal_type(x)
  |                     ^ `(int & ~<Protocol with members 'capitalize'>) | LiteralString`
  |

The protocol synthesized from the predicate hasattr(x, "capitalize") is Protocol[{"capitalize": object}].
Currently, we check the compatibility of protocol members and attributes with member_type.has_relation_to(db, attribute_type, relation) && attribute_type.has_relation_to(db, *member_type, relation), which leads to the paradoxical situation where LiteralString does not satisfy the protocol.

@AlexWaygood
Copy link
Member

Ah, nice catch. The synthesised protocols created from hasattr narrowing should use covariant read-only property members rather than invariant mutable attribute members. I think just making that change should fix things there for now, though we'll obviously need to do a lot more work on property members in the future since they'll require some special-cased subtyping logic of their own

@mtshiba
Copy link
Collaborator Author

mtshiba commented Jun 28, 2025

Now all the new errors/warnings in mypy_primer look ok.
They are either due to checks working as intended (writing to hasattr attributes, etc.) or due to TODOs (Protocol members with default values, @property members, generic members).

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.

Thanks again -- this is great work!

@AlexWaygood
Copy link
Member

I think we'll need to invest into improving the diagnostic if somebody tries to write to an attribute on a synthesized protocol that has that attribute available, but only as a read-only property member. But we can defer that for now.

@AlexWaygood AlexWaygood enabled auto-merge (squash) June 29, 2025 10:45
@AlexWaygood AlexWaygood merged commit de1f817 into astral-sh:main Jun 29, 2025
35 checks passed
@mtshiba mtshiba deleted the protocol-member-checks branch June 29, 2025 15:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ty Multi-file analysis & type inference

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants