Skip to content

Conversation

@med1844
Copy link
Contributor

@med1844 med1844 commented Jun 9, 2025

Summary

Previously, the checks for implicit attribute assignments didn't properly account for method decorators. This PR fixes that by:

  • Adding a decorator check in implicit_instance_attribute. This allows it to filter out methods with mismatching decorators when analyzing attribute assignments.
  • Adding attribute search for implicit class attributes: if an attribute can't be found directly in the class body, the ClassLiteral::own_class_member function will now search in classmethods.
  • Adding staticmethod: it has been added into KnownClass and together with the new decorator check, it will no longer expose attributes when the assignment target name is the same as the first method name.

If accepted, it should fix astral-sh/ty#205 and astral-sh/ty#207.

Test Plan

This is tested with existing mdtest suites and is able to get most of the TODO marks for implicit assignments in classmethods and staticmethods removed.

However, there's one specific test case I failed to figure out how to correctly resolve:

https://github.com/med1844/ruff/blob/b279508bdc63c1ed6fc4ccf9d43d3719fe7a202b/crates/ty_python_semantic/resources/mdtest/attributes.md?plain=1#L754-L755

I tried to add instance_member().is_unbound() check in this else branch but it causes tests with class attributes defined in class body to fail. While it's possible to implicitly add ClassVar to qualifiers to make this assignment fail and keep everything else passing, it doesn't feel like the right solution.

Another problem is that this PR also kind of breaks dependency_implicit_instance_attribute and dependency_own_instance_member in src/types/infer.rs. This seems to be caused by the new decorator check, which uses the inferred method function type to get the inferred decorator type. Thus, any change inside function body causes re-inferring method type. As a result, both "no re-infer" assertions in these two tests fails. I'm not 100% sure, but that's my current theory.

Thank you very much for taking time to review 🙏

@med1844 med1844 changed the title Add decorator check for implicit attribute assignments [ty] Add decorator check for implicit attribute assignments Jun 9, 2025
@github-actions
Copy link
Contributor

github-actions bot commented Jun 9, 2025

mypy_primer results

Changes were detected when running on open source projects
rich (https://github.com/Textualize/rich)
- warning[possibly-unbound-attribute] tests/test_text.py:247:9: Attribute `link` on type `str | Style` is possibly unbound
+ warning[possibly-unbound-attribute] tests/test_text.py:247:9: Attribute `link` on type `Unknown | str | Style` is possibly unbound
- warning[possibly-unbound-attribute] tests/test_text.py:261:9: Attribute `link` on type `str | Style` is possibly unbound
+ warning[possibly-unbound-attribute] tests/test_text.py:261:9: Attribute `link` on type `Unknown | str | Style` is possibly unbound

cki-lib (https://gitlab.com/cki-project/cki-lib)
+ error[invalid-assignment] tests/test_psql.py:40:9: Object of type `Literal[0]` is not assignable to attribute `closed` on type `Unknown | None`
+ error[invalid-assignment] tests/test_psql.py:50:9: Object of type `Literal[2]` is not assignable to attribute `closed` on type `Unknown | None`
+ error[invalid-assignment] tests/test_psql.py:83:9: Object of type `Literal[0]` is not assignable to attribute `closed` on type `Unknown | None`
- Found 166 diagnostics
+ Found 169 diagnostics

colour (https://github.com/colour-science/colour)
- error[invalid-assignment] colour/recovery/tests/test_jakob2019.py:221:13: Object of type `SpectralShape` is not assignable to attribute `_shape` on type `type[TestLUT3D_Jakob2019] & ~<Protocol with members '_LUT'>`
- error[invalid-assignment] colour/recovery/tests/test_jakob2019.py:222:13: Object of type `MultiSpectralDistributions` is not assignable to attribute `_cmfs` on type `type[TestLUT3D_Jakob2019] & ~<Protocol with members '_LUT'>`
- error[invalid-assignment] colour/recovery/tests/test_jakob2019.py:222:24: Object of type `SpectralDistribution` is not assignable to attribute `_sd_D65` on type `type[TestLUT3D_Jakob2019] & ~<Protocol with members '_LUT'>`
- error[invalid-assignment] colour/recovery/tests/test_jakob2019.py:223:13: Object of type `Unknown` is not assignable to attribute `_XYZ_D65` on type `type[TestLUT3D_Jakob2019] & ~<Protocol with members '_LUT'>`
- error[invalid-assignment] colour/recovery/tests/test_jakob2019.py:224:13: Object of type `Unknown` is not assignable to attribute `_xy_D65` on type `type[TestLUT3D_Jakob2019] & ~<Protocol with members '_LUT'>`
- error[invalid-assignment] colour/recovery/tests/test_jakob2019.py:226:13: Object of type `RGB_Colourspace` is not assignable to attribute `_RGB_colourspace` on type `type[TestLUT3D_Jakob2019] & ~<Protocol with members '_LUT'>`
- error[invalid-assignment] colour/recovery/tests/test_jakob2019.py:228:13: Object of type `LUT3D_Jakob2019` is not assignable to attribute `_LUT` on type `type[TestLUT3D_Jakob2019] & ~<Protocol with members '_LUT'>`
- error[unresolved-attribute] colour/recovery/tests/test_jakob2019.py:231:16: Attribute `_LUT` can only be accessed on instances, not on the class object `type[TestLUT3D_Jakob2019]` itself.
+ warning[possibly-unbound-attribute] colour/recovery/tests/test_jakob2019.py:221:13: Attribute `_shape` on type `type[TestLUT3D_Jakob2019]` is possibly unbound
+ warning[possibly-unbound-attribute] colour/recovery/tests/test_jakob2019.py:222:13: Attribute `_cmfs` on type `type[TestLUT3D_Jakob2019]` is possibly unbound
+ warning[possibly-unbound-attribute] colour/recovery/tests/test_jakob2019.py:222:24: Attribute `_sd_D65` on type `type[TestLUT3D_Jakob2019]` is possibly unbound
+ warning[possibly-unbound-attribute] colour/recovery/tests/test_jakob2019.py:223:13: Attribute `_XYZ_D65` on type `type[TestLUT3D_Jakob2019]` is possibly unbound
+ warning[possibly-unbound-attribute] colour/recovery/tests/test_jakob2019.py:224:13: Attribute `_xy_D65` on type `type[TestLUT3D_Jakob2019]` is possibly unbound
+ warning[possibly-unbound-attribute] colour/recovery/tests/test_jakob2019.py:226:13: Attribute `_RGB_colourspace` on type `type[TestLUT3D_Jakob2019]` is possibly unbound
+ warning[possibly-unbound-attribute] colour/recovery/tests/test_jakob2019.py:228:13: Attribute `_LUT` on type `type[TestLUT3D_Jakob2019]` is possibly unbound
+ warning[possibly-unbound-attribute] colour/recovery/tests/test_jakob2019.py:231:16: Attribute `_LUT` on type `type[TestLUT3D_Jakob2019]` is possibly unbound

bandersnatch (https://github.com/pypa/bandersnatch)
+ error[missing-argument] src/bandersnatch_storage_plugins/swift.py:172:16: No argument provided for required parameter `path` of bound method `mkdir`
+ error[missing-argument] src/bandersnatch_storage_plugins/swift.py:178:13: No argument provided for required parameter `path` of bound method `delete_file`
+ error[missing-argument] src/bandersnatch_storage_plugins/swift.py:187:16: No arguments provided for required parameters `source`, `dest` of bound method `copy_file`
+ error[missing-argument] src/bandersnatch_storage_plugins/swift.py:192:13: No argument provided for required parameter `path` of bound method `rmdir`
+ error[missing-argument] src/bandersnatch_storage_plugins/swift.py:199:16: No arguments provided for required parameters `source`, `dest` of bound method `copy_file`
+ error[missing-argument] src/bandersnatch_storage_plugins/swift.py:203:16: No arguments provided for required parameters `source`, `dest` of bound method `copy_file`
- error[unresolved-attribute] src/bandersnatch_storage_plugins/swift.py:128:13: Attribute `BACKEND` can only be accessed on instances, not on the class object `<class '_SwiftAccessor'>` itself.
- error[unresolved-attribute] src/bandersnatch_storage_plugins/swift.py:138:14: Attribute `BACKEND` can only be accessed on instances, not on the class object `<class '_SwiftAccessor'>` itself.
- error[unresolved-attribute] src/bandersnatch_storage_plugins/swift.py:140:17: Attribute `BACKEND` can only be accessed on instances, not on the class object `<class '_SwiftAccessor'>` itself.
- error[unresolved-attribute] src/bandersnatch_storage_plugins/swift.py:156:14: Attribute `BACKEND` can only be accessed on instances, not on the class object `<class '_SwiftAccessor'>` itself.
- error[unresolved-attribute] src/bandersnatch_storage_plugins/swift.py:158:17: Attribute `BACKEND` can only be accessed on instances, not on the class object `<class '_SwiftAccessor'>` itself.
- error[unresolved-attribute] src/bandersnatch_storage_plugins/swift.py:172:16: Attribute `BACKEND` can only be accessed on instances, not on the class object `<class '_SwiftAccessor'>` itself.
- error[unresolved-attribute] src/bandersnatch_storage_plugins/swift.py:178:13: Attribute `BACKEND` can only be accessed on instances, not on the class object `<class '_SwiftAccessor'>` itself.
- error[unresolved-attribute] src/bandersnatch_storage_plugins/swift.py:187:16: Attribute `BACKEND` can only be accessed on instances, not on the class object `<class '_SwiftAccessor'>` itself.
- error[unresolved-attribute] src/bandersnatch_storage_plugins/swift.py:192:13: Attribute `BACKEND` can only be accessed on instances, not on the class object `<class '_SwiftAccessor'>` itself.
- error[unresolved-attribute] src/bandersnatch_storage_plugins/swift.py:199:16: Attribute `BACKEND` can only be accessed on instances, not on the class object `<class '_SwiftAccessor'>` itself.
- error[unresolved-attribute] src/bandersnatch_storage_plugins/swift.py:203:16: Attribute `BACKEND` can only be accessed on instances, not on the class object `<class '_SwiftAccessor'>` itself.
- error[unresolved-attribute] src/bandersnatch_storage_plugins/swift.py:213:16: Attribute `BACKEND` can only be accessed on instances, not on the class object `<class '_SwiftAccessor'>` itself.
- error[unresolved-attribute] src/bandersnatch_storage_plugins/swift.py:219:16: Attribute `BACKEND` can only be accessed on instances, not on the class object `<class '_SwiftAccessor'>` itself.
- Found 132 diagnostics
+ Found 125 diagnostics

mitmproxy (https://github.com/mitmproxy/mitmproxy)
- error[invalid-return-type] examples/contrib/ntlm_upstream_proxy.py:79:33: Return type does not match returned value: expected `HTTPFlow`, found `(@Todo(Type::Intersection.call()) & HTTPFlow) | None`
+ error[invalid-return-type] examples/contrib/ntlm_upstream_proxy.py:79:33: Return type does not match returned value: expected `HTTPFlow`, found `@Todo(Type::Intersection.call()) | (@Todo(Type::Intersection.call()) & HTTPFlow) | None`

freqtrade (https://github.com/freqtrade/freqtrade)
- error[unresolved-attribute] freqtrade/rpc/api_server/deps.py:19:16: Attribute `_rpc` can only be accessed on instances, not on the class object `<class 'ApiServer'>` itself.
- error[invalid-attribute-access] freqtrade/rpc/api_server/webserver.py:80:13: Cannot assign to instance attribute `_rpc` from the class object `<class 'ApiServer'>`
- error[unresolved-attribute] freqtrade/rpc/api_server/webserver.py:89:13: Attribute `_rpc` can only be accessed on instances, not on the class object `<class 'ApiServer'>` itself.
- Found 445 diagnostics
+ Found 442 diagnostics

scrapy (https://github.com/scrapy/scrapy)
- error[unresolved-attribute] tests/test_closespider.py:30:18: Type `Spider | None` has no attribute `meta`
- error[unresolved-attribute] tests/test_closespider.py:40:18: Type `Spider | None` has no attribute `meta`
- error[unresolved-attribute] tests/test_closespider.py:59:18: Type `Spider | None` has no attribute `meta`
- error[unresolved-attribute] tests/test_closespider.py:77:18: Type `Spider | None` has no attribute `meta`
- error[unresolved-attribute] tests/test_closespider.py:87:18: Type `Spider | None` has no attribute `meta`
- error[unresolved-attribute] tests/test_closespider.py:89:36: Type `Spider | None` has no attribute `exception_cls`
- error[unresolved-attribute] tests/test_closespider.py:99:18: Type `Spider | None` has no attribute `meta`
- error[unresolved-attribute] tests/test_closespider.py:109:18: Type `Spider | None` has no attribute `meta`
- error[unresolved-attribute] tests/test_contracts.py:535:16: Type `Spider | None` has no attribute `visited`
- error[unresolved-attribute] tests/test_crawl.py:79:20: Type `Spider | None` has no attribute `urls_visited`
- error[unresolved-attribute] tests/test_crawl.py:127:16: Type `Spider | None` has no attribute `t1`
- error[unresolved-attribute] tests/test_crawl.py:128:16: Type `Spider | None` has no attribute `t2`
- error[unresolved-attribute] tests/test_crawl.py:129:16: Type `Spider | None` has no attribute `t2`
- error[unresolved-attribute] tests/test_crawl.py:129:36: Type `Spider | None` has no attribute `t1`
- error[unresolved-attribute] tests/test_crawl.py:135:16: Type `Spider | None` has no attribute `t1`
- error[unresolved-attribute] tests/test_crawl.py:136:16: Type `Spider | None` has no attribute `t2`
- error[unresolved-attribute] tests/test_crawl.py:137:16: Type `Spider | None` has no attribute `t2_err`
- error[unresolved-attribute] tests/test_crawl.py:138:16: Type `Spider | None` has no attribute `t2_err`
- error[unresolved-attribute] tests/test_crawl.py:138:40: Type `Spider | None` has no attribute `t1`
- error[unresolved-attribute] tests/test_crawl.py:143:16: Type `Spider | None` has no attribute `t1`
- error[unresolved-attribute] tests/test_crawl.py:144:16: Type `Spider | None` has no attribute `t2`
- error[unresolved-attribute] tests/test_crawl.py:145:16: Type `Spider | None` has no attribute `t2_err`
- error[unresolved-attribute] tests/test_crawl.py:146:16: Type `Spider | None` has no attribute `t2_err`
- error[unresolved-attribute] tests/test_crawl.py:146:40: Type `Spider | None` has no attribute `t1`
- error[unresolved-attribute] tests/test_crawl.py:243:16: Type `Spider | None` has no attribute `visited`
- error[unresolved-attribute] tests/test_crawl.py:252:16: Type `Spider | None` has no attribute `visited`
- error[unresolved-attribute] tests/test_crawl.py:325:31: Type `Spider | None` has no attribute `meta`
- error[unresolved-attribute] tests/test_crawl.py:326:34: Type `Spider | None` has no attribute `meta`
- error[unresolved-attribute] tests/test_crawl.py:328:39: Type `Spider | None` has no attribute `meta`
- error[unresolved-attribute] tests/test_crawl.py:331:39: Type `Spider | None` has no attribute `meta`
- error[unresolved-attribute] tests/test_crawl.py:334:39: Type `Spider | None` has no attribute `meta`
- error[unresolved-attribute] tests/test_crawl.py:337:39: Type `Spider | None` has no attribute `meta`
- error[invalid-argument-type] tests/test_crawl.py:347:42: Argument to function `get_engine_status` is incorrect: Expected `ExecutionEngine`, found `Unknown | ExecutionEngine | None`
- warning[possibly-unbound-attribute] tests/test_crawl.py:355:43: Attribute `name` on type `Spider | None` is possibly unbound
- error[invalid-argument-type] tests/test_crawl.py:365:45: Argument to function `format_engine_status` is incorrect: Expected `ExecutionEngine`, found `Unknown | ExecutionEngine | None`
- warning[possibly-unbound-attribute] tests/test_crawl.py:380:43: Attribute `name` on type `Spider | None` is possibly unbound
- error[unresolved-attribute] tests/test_crawl.py:634:16: Type `Spider | None` has no attribute `meta`
- error[unresolved-attribute] tests/test_crawl.py:641:16: Type `Spider | None` has no attribute `meta`
- error[unresolved-attribute] tests/test_crawl.py:654:16: Type `Spider | None` has no attribute `meta`
- error[unresolved-attribute] tests/test_crawl.py:664:22: Type `Spider | None` has no attribute `meta`
- error[unresolved-attribute] tests/test_crawl.py:673:22: Type `Spider | None` has no attribute `meta`
- error[unresolved-attribute] tests/test_crawl.py:681:16: Type `Spider | None` has no attribute `meta`
- error[unresolved-attribute] tests/test_crawl.py:682:27: Type `Spider | None` has no attribute `meta`
- error[unresolved-attribute] tests/test_crawl.py:683:16: Type `Spider | None` has no attribute `meta`
- error[unresolved-attribute] tests/test_crawl.py:683:56: Type `Spider | None` has no attribute `meta`
- error[unresolved-attribute] tests/test_crawl.py:687:17: Type `Spider | None` has no attribute `meta`
- error[unresolved-attribute] tests/test_crawl.py:688:15: Type `Spider | None` has no attribute `full_response_length`
- error[unresolved-attribute] tests/test_crawl.py:695:16: Type `Spider | None` has no attribute `meta`
- error[unresolved-attribute] tests/test_crawl.py:696:27: Type `Spider | None` has no attribute `meta`
- error[unresolved-attribute] tests/test_crawl.py:697:27: Type `Spider | None` has no attribute `meta`
- error[unresolved-attribute] tests/test_crawl.py:698:27: Type `Spider | None` has no attribute `meta`
- error[unresolved-attribute] tests/test_crawl.py:699:16: Type `Spider | None` has no attribute `meta`
- error[unresolved-attribute] tests/test_crawl.py:701:34: Type `Spider | None` has no attribute `meta`
- error[unresolved-attribute] tests/test_crawl.py:703:17: Type `Spider | None` has no attribute `meta`
- error[unresolved-attribute] tests/test_crawl.py:704:15: Type `Spider | None` has no attribute `full_response_length`
- error[unresolved-attribute] tests/test_crawl.py:711:16: Type `Spider | None` has no attribute `meta`
- error[unresolved-attribute] tests/test_crawl.py:712:27: Type `Spider | None` has no attribute `meta`
- error[unresolved-attribute] tests/test_crawl.py:713:16: Type `Spider | None` has no attribute `meta`
- error[unresolved-attribute] tests/test_crawl.py:713:59: Type `Spider | None` has no attribute `meta`
- error[unresolved-attribute] tests/test_crawl.py:721:16: Type `Spider | None` has no attribute `meta`
- error[unresolved-attribute] tests/test_crawl.py:722:27: Type `Spider | None` has no attribute `meta`
- error[unresolved-attribute] tests/test_crawl.py:723:27: Type `Spider | None` has no attribute `meta`
- error[unresolved-attribute] tests/test_crawl.py:724:27: Type `Spider | None` has no attribute `meta`
- error[unresolved-attribute] tests/test_crawl.py:725:16: Type `Spider | None` has no attribute `meta`
- error[unresolved-attribute] tests/test_crawl.py:727:37: Type `Spider | None` has no attribute `meta`
- error[unresolved-attribute] tests/test_downloader_handlers_http_base.py:617:19: Type `Spider | None` has no attribute `meta`
- error[unresolved-attribute] tests/test_downloader_handlers_http_base.py:628:19: Type `Spider | None` has no attribute `meta`
- error[unresolved-attribute] tests/test_downloader_handlers_http_base.py:630:18: Type `Spider | None` has no attribute `meta`
- warning[possibly-unbound-attribute] tests/test_downloaderslotssettings.py:69:17: Attribute `downloader` on type `ExecutionEngine | None` is possibly unbound
- error[unresolved-attribute] tests/test_downloaderslotssettings.py:70:17: Type `Spider | None` has no attribute `times`
- error[invalid-assignment] tests/test_pqueues.py:101:9: Object of type `MockEngine` is not assignable to attribute `engine` of type `ExecutionEngine | None`
- error[unresolved-attribute] tests/test_proxy_connect.py:116:27: Type `Spider | None` has no attribute `meta`
- error[unresolved-attribute] tests/test_request_attribute_binding.py:74:20: Type `Spider | None` has no attribute `meta`
- error[unresolved-attribute] tests/test_request_attribute_binding.py:83:23: Type `Spider | None` has no attribute `meta`
- error[unresolved-attribute] tests/test_request_attribute_binding.py:100:19: Type `Spider | None` has no attribute `meta`
- error[unresolved-attribute] tests/test_request_attribute_binding.py:133:20: Type `Spider | None` has no attribute `meta`
- error[unresolved-attribute] tests/test_request_attribute_binding.py:166:20: Type `Spider | None` has no attribute `meta`
- error[unresolved-attribute] tests/test_request_attribute_binding.py:189:20: Type `Spider | None` has no attribute `meta`
- error[unresolved-attribute] tests/test_request_cb_kwargs.py:167:20: Type `Spider | None` has no attribute `checks`
- error[unresolved-attribute] tests/test_request_cb_kwargs.py:168:20: Type `Spider | None` has no attribute `checks`
- error[unresolved-attribute] tests/test_request_left.py:41:16: Type `Spider | None` has no attribute `caught_times`
- error[unresolved-attribute] tests/test_request_left.py:47:16: Type `Spider | None` has no attribute `caught_times`
- error[unresolved-attribute] tests/test_request_left.py:53:16: Type `Spider | None` has no attribute `caught_times`
- error[unresolved-attribute] tests/test_request_left.py:59:16: Type `Spider | None` has no attribute `caught_times`
- error[unresolved-attribute] tests/test_spidermiddleware_httperror.py:189:20: Type `Spider | None` has no attribute `skipped`
- error[unresolved-attribute] tests/test_spidermiddleware_httperror.py:189:44: Type `Spider | None` has no attribute `skipped`
- error[unresolved-attribute] tests/test_spidermiddleware_httperror.py:190:16: Type `Spider | None` has no attribute `parsed`
- error[unresolved-attribute] tests/test_spidermiddleware_httperror.py:191:16: Type `Spider | None` has no attribute `failed`
- error[unresolved-attribute] tests/test_spidermiddleware_httperror.py:204:16: Type `Spider | None` has no attribute `parsed`
- error[unresolved-attribute] tests/test_spidermiddleware_httperror.py:205:16: Type `Spider | None` has no attribute `skipped`
- error[unresolved-attribute] tests/test_spidermiddleware_httperror.py:206:16: Type `Spider | None` has no attribute `failed`
- error[unresolved-attribute] tests/test_spidermiddleware_httperror.py:219:16: Type `Spider | None` has no attribute `parsed`
- error[unresolved-attribute] tests/test_spidermiddleware_httperror.py:220:16: Type `Spider | None` has no attribute `failed`
- error[unresolved-attribute] tests/test_spidermiddleware_httperror.py:231:16: Type `Spider | None` has no attribute `parsed`
- error[unresolved-attribute] tests/test_spidermiddleware_httperror.py:232:16: Type `Spider | None` has no attribute `failed`
- Found 1253 diagnostics
+ Found 1158 diagnostics

pytest (https://github.com/pytest-dev/pytest)
+ warning[unused-ignore-comment] testing/test_terminal.py:1988:33: Unused blanket `type: ignore` directive
- Found 647 diagnostics
+ Found 648 diagnostics

prefect (https://github.com/PrefectHQ/prefect)
- warning[possibly-unbound-attribute] src/prefect/server/orchestration/global_policy.py:460:13: Attribute `state_details` on type `(Unknown & State) | (Unknown & None) | Unknown | State | None` is possibly unbound
+ warning[possibly-unbound-attribute] src/prefect/server/orchestration/global_policy.py:460:13: Attribute `state_details` on type `Unknown | (Unknown & None) | (State & @Todo(specialized non-generic class)) | None` is possibly unbound

dd-trace-py (https://github.com/DataDog/dd-trace-py)
- error[invalid-assignment] tests/ci_visibility/api/fake_runner_efd_all_pass.py:25:5: Object of type `int` is not assignable to attribute `start_ns` on type `Span | None`
+ error[invalid-assignment] tests/ci_visibility/api/fake_runner_efd_all_pass.py:25:5: Object of type `Unknown | int` is not assignable to attribute `start_ns` on type `Span | None`
- error[invalid-assignment] tests/ci_visibility/api/fake_runner_efd_mix_fail.py:25:5: Object of type `int` is not assignable to attribute `start_ns` on type `Span | None`
+ error[invalid-assignment] tests/ci_visibility/api/fake_runner_efd_mix_fail.py:25:5: Object of type `Unknown | int` is not assignable to attribute `start_ns` on type `Span | None`
- error[invalid-assignment] tests/ci_visibility/api/fake_runner_efd_mix_pass.py:25:5: Object of type `int` is not assignable to attribute `start_ns` on type `Span | None`
+ error[invalid-assignment] tests/ci_visibility/api/fake_runner_efd_mix_pass.py:25:5: Object of type `Unknown | int` is not assignable to attribute `start_ns` on type `Span | None`

zulip (https://github.com/zulip/zulip)
+ error[invalid-assignment] zerver/lib/narrow.py:882:17: Object of type `Unknown | str | int | list[int]` is not assignable to `str | int`
+ error[invalid-argument-type] zerver/lib/narrow.py:911:70: Argument to function `get_stream_by_narrow_operand_access_unchecked` is incorrect: Expected `str | int`, found `Unknown | str | int | list[int]`
+ error[invalid-argument-type] zerver/lib/narrow.py:945:69: Argument to function `maybe_rename_general_chat_to_empty_topic` is incorrect: Expected `str`, found `Unknown | str | int | list[int]`
+ error[unsupported-operator] zerver/views/message_fetch.py:209:42: Operator `+` is unsupported between objects of type `Literal["is:"]` and `Unknown | str | int | list[int]`
- Found 7465 diagnostics
+ Found 7469 diagnostics

@codspeed-hq
Copy link

codspeed-hq bot commented Jun 9, 2025

CodSpeed Instrumentation Performance Report

Merging #18587 will degrade performances by 6.91%

Comparing med1844:check_assignments_in_classmethods (08be875) with main (ca79338)

Summary

❌ 1 (👁 1) regressions
✅ 36 untouched benchmarks

Benchmarks breakdown

Benchmark BASE HEAD Change
👁 ty_micro[many_string_assignments] 66.5 ms 71.5 ms -6.91%

@med1844
Copy link
Contributor Author

med1844 commented Jun 9, 2025

mypy primer looks really bad... converting to draft for now. I think I might have messed up staticmethods...

@med1844 med1844 marked this pull request as draft June 9, 2025 03:15
@med1844
Copy link
Contributor Author

med1844 commented Jun 9, 2025

Removing the mapping "staticmethod" => Self::StaticMethod in KnownClass::try_from_file_and_name seems to fix mypy primer, but this will break the known class round trip test. Will look further into this tomorrow.

@AlexWaygood AlexWaygood added the ty Multi-file analysis & type inference label Jun 9, 2025
@sharkdp
Copy link
Contributor

sharkdp commented Jun 10, 2025

Thank you for working on this. I'm not doing a review for now since it's still a draft. Let us know when you need help with this.

@med1844 med1844 force-pushed the check_assignments_in_classmethods branch from 01ddbfa to 065cb83 Compare June 10, 2025 14:27
@med1844
Copy link
Contributor Author

med1844 commented Jun 11, 2025

It took me a good while to manually check why there's so many more diagnostics in mypy_primer. For example in apprise there's 774 more diagnostics.

I thought it's me that screwed up some staticmethod property, but further inspection shows that it's because now staticmethod are supported, it actually enables ty to check more code, and the diagnostics yielded are actually correct.

As for apprise, it turns out it's caused by the combination of checking more code, and low quality .pyi stub files. For example, NotifyBase defined in normal python file inherits URLBase, but in the stub file it doesn't! I checked some other diagnostics in apprise and they all seem to point to some faulty .pyi file. By modifying this .pyi to inherit URLBase and re-run ty again locally, I was able to get rid of 61 diagnostics...

So, the conclusion is that to my best knowledge, the staticmethod implementation itself is ok. I'm change this PR to be ready for review.

@med1844 med1844 marked this pull request as ready for review June 11, 2025 04:41
@med1844 med1844 marked this pull request as draft June 13, 2025 00:34
@med1844 med1844 force-pushed the check_assignments_in_classmethods branch from 065cb83 to be588ca Compare June 17, 2025 04:46
sharkdp pushed a commit that referenced this pull request Jun 20, 2025
## Summary

Add support for `@staticmethod`s. Overall, the changes are very similar
to #16305.

#18587 will be dependent on this PR for a potential fix of
astral-sh/ty#207.

mypy_primer will look bad since the new code allows ty to check more
code.

## Test Plan

Added new markdown tests. Please comment if there's any missing tests
that I should add in, thank you.
@med1844 med1844 force-pushed the check_assignments_in_classmethods branch from be588ca to c34582f Compare June 21, 2025 22:26
@med1844 med1844 marked this pull request as ready for review June 21, 2025 22:57
@sharkdp
Copy link
Contributor

sharkdp commented Jun 23, 2025

Thank you very much for working on this! I am planning to review this tomorrow.

Copy link
Contributor

@sharkdp sharkdp 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 very much — this is great!

In particular, thank you for going through the salsa debugging session and for adding a incremental-computation test.

While looking through the ecosystem changes, I noticed a behavior related to attributes that are implicitly defined in methods with an unknown decorator:

class C:
    @unknown_decorator
    def f(self):
        self.x: int = 1

C.x  # this was previously an error

We now consider these attributes to be available on the class itself. I think this is actually a good example of the gradual guarantee: unknown_decorator could, in principle, be an alias to builtins.classmethod. And so it seems good to not show an error here. Because if unknown_decorator was annotated appropriately, we would not show an error as well.

I added a test to document this.

@sharkdp
Copy link
Contributor

sharkdp commented Jun 24, 2025

However, there's one specific test case I failed to figure out how to correctly resolve:

https://github.com/med1844/ruff/blob/b279508bdc63c1ed6fc4ccf9d43d3719fe7a202b/crates/ty_python_semantic/resources/mdtest/attributes.md?plain=1#L754-L755

I tried to add instance_member().is_unbound() check in this else branch but it causes tests with class attributes defined in class body to fail. While it's possible to implicitly add ClassVar to qualifiers to make this assignment fail and keep everything else passing, it doesn't feel like the right solution.

I think it's fine to keep this for a follow-up changeset. I generally agree with your analysis, but maybe adding an implicit ClassVar qualifier to attributes that are defined in @classmethods wouldn't be too bad? This would mean that we handle the x attributes on the following two classes equivalently, which seems like a good thing?

class C1:
    x: ClassVar[int]

class C2:
    @classmethod
    def method(cls):
        cls.x: int

If this is inconsistent for some reason (maybe when printing the type + qualifiers of C2.x somewhere), we could perhaps add a new ImplicitClassVar qualifier with similar behavior?

@sharkdp
Copy link
Contributor

sharkdp commented Jun 24, 2025

Merging #18587 will degrade performances by 6.91%
ty_micro[many_string_assignments]

I acknowledged this performance regression. There are also a few smaller 1-2% regressions in other benchmarks. On this branch, we need to do a lot more work on every attribute access obj.x. Since we always look up x on the type of obj first, and x might be implicitly defined in a @classmethod on the type of obj, we now go through all methods on the type of obj twice, instead of just once (looking for implicit instance attributes).

The many_string_assignments benchmark is affected in particular, presumably because it accesses attributes (e.g. __add__) on str with a lot of methods.

@sharkdp sharkdp merged commit fd2cc37 into astral-sh:main Jun 24, 2025
36 checks passed
@med1844
Copy link
Contributor Author

med1844 commented Jun 24, 2025

Thank you for the review and the commits! Sorry I was not paying enough attention to code comments.

Regarding the test case:

but maybe adding an implicit ClassVar qualifier to attributes that are defined in @classmethods wouldn't be too bad?

My main concern is that it would be surprising for users to see C2.x implicitly become a class variable in the diagnostics without being explicitly marked as such. It could be especially confusing for those not already familiar with ClassVar.

If this is inconsistent for some reason (maybe when printing the type + qualifiers of C2.x somewhere), we could perhaps add a new ImplicitClassVar qualifier with similar behavior?

I'm not a huge fan of adding something so similar to ClassVar, but if there isn't a more elegant approach, this would be the best option. It would also allow us to provide a more user-friendly diagnostic message.

If we decides to go with ImplicitClassVar, I will create a follow up PR, which should be relatively small.

dcreager added a commit that referenced this pull request Jun 24, 2025
* main:
  [ty] Fix false positives when subscripting an object inferred as having an `Intersection` type (#18920)
  [`flake8-use-pathlib`] Add autofix for `PTH202` (#18763)
  [ty] Add relative import completion tests
  [ty] Clarify what "cursor" means
  [ty] Add a cursor test builder
  [ty] Enforce sort order of completions (#18917)
  [formatter] Fix missing blank lines before decorated classes in .pyi files (#18888)
  Apply fix availability and applicability when adding to `DiagnosticGuard` and remove `NoqaCode::rule` (#18834)
  py-fuzzer: allow relative executable paths (#18915)
  [ty] Change `environment.root` to accept multiple paths (#18913)
  [ty] Rename `src.root` setting to `environment.root` (#18760)
  Use file path for detecting package root (#18914)
  Consider virtual path for various server actions (#18910)
  [ty] Introduce `UnionType::try_from_elements` and `UnionType::try_map` (#18911)
  [ty] Support narrowing on `isinstance()`/`issubclass()` if the second argument is a dynamic, intersection, union or typevar type (#18900)
  [ty] Add decorator check for implicit attribute assignments (#18587)
  [`ruff`] Trigger `RUF037` for empty string and byte strings (#18862)
  [ty] Avoid duplicate diagnostic in unpacking (#18897)
  [`pyupgrade`] Extend version detection to include `sys.version_info.major` (`UP036`) (#18633)
  [`ruff`] Frozen Dataclass default should be valid (`RUF009`) (#18735)
@sharkdp
Copy link
Contributor

sharkdp commented Jun 25, 2025

My main concern is that it would be surprising for users to see C2.x implicitly become a class variable in the diagnostics without being explicitly marked as such. It could be especially confusing for those not already familiar with ClassVar.

👍

if there isn't a more elegant approach

I think we'll eventually want to add a richer return type for methods such as Type::member_lookup_with_policy that would not just return type, boundness information, and qualifiers, but also other metadata such as:

  • was the attribute found on the meta type or on the instance?
  • did we end up calling a descriptor __get__ method while resolving the attribute access?
  • if so, was is a data or a non-data descriptor?
  • if we could not resolve the attribute access, what was the reason (not found anywhere, call to __get__ failed, …)

In this process, we could probably also add metadata for what is needed here.

I'm not a huge fan of adding something so similar to ClassVar

I think we could probably try to build a nice abstraction? TypeQualifiers could become a single-field struct that stores the bitflags internally. We could then provide an API that can not be misused: a is_class_var() method that would return true for both explicit and "implicit" ClassVars. And a second method for displaying/explaining a type with such a qualifier to the user, that would distinguish between the two flags.

What I'm more concerned about is the fact that type qualifiers have a rather precise meaning, and they are only associated with declared / explicitly annotated symbols or attributes. With this change, we would also attach non-trivial type qualifiers to attributes that were defined without an annotation (cls.x = 1 in a @classmethod).

@med1844
Copy link
Contributor Author

med1844 commented Jun 25, 2025

I'm not sure if I fully understands your points, here's my understanding:

  • We can add ImplicitClassVar, and also add more methods into TypeQualifiers to provide better abstraction.
  • This will be replaced with a separate metadata field in future.

The metadata approach seems to solve the root problem and is extensible, though I guess it needs a little bit more refactoring.

@sharkdp
Copy link
Contributor

sharkdp commented Jun 25, 2025

The metadata approach seems to solve the root problem and is extensible, though I guess it needs a little bit more refactoring.

Yes, exactly. I'm afraid this would be a larger project, but the issue here is not that urgent, I think. So it might make sense to just wait for that metadata refactoring/extension.

@med1844
Copy link
Contributor Author

med1844 commented Jun 26, 2025

Sounds good! I will work on other issues while waiting for the refactor.

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

Labels

ecosystem-analyzer ty Multi-file analysis & type inference

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support implicit class attributes (only defined in a @classmethod)

5 participants