Skip to content

[ty] Infer class attributes assigned by metaclass initialization#25342

Merged
charliermarsh merged 9 commits into
mainfrom
charlie/codex-metaclass-init-class-attributes
May 27, 2026
Merged

[ty] Infer class attributes assigned by metaclass initialization#25342
charliermarsh merged 9 commits into
mainfrom
charlie/codex-metaclass-init-class-attributes

Conversation

@charliermarsh

@charliermarsh charliermarsh commented May 23, 2026

Copy link
Copy Markdown
Member

Summary

Prior to this change, we didn't recognize class-object attributes populated by a metaclass during class initialization. For example:

class Meta(type):
    def __init__(cls, name: str, bases: tuple[type, ...], namespace: dict[str, object]) -> None:
        cls.attr: int = 1

class C(metaclass=Meta): ...

reveal_type(C.attr)  # revealed: int

We now recognize attributes that a metaclass adds to a class while creating it. If the metaclass overwrites an existing class-body value, we use the overwritten value's type. If the class provides an annotation for the generated attribute, we preserve that annotation as its public type.

For example, if the class body initially gives attr a value, the metaclass assignment happens later at runtime and overwrites it:

class Meta(type):
    def __init__(cls, name, bases, namespace):
        cls.attr: int = 1

class C(metaclass=Meta):
    attr = "initial value"

reveal_type(C.attr)  # int

Closes astral-sh/ty#1138.

@astral-sh-bot astral-sh-bot Bot added the ty Multi-file analysis & type inference label May 23, 2026
@astral-sh-bot

astral-sh-bot Bot commented May 23, 2026

Copy link
Copy Markdown

Typing conformance results

No changes detected ✅

Current numbers
The percentage of diagnostics emitted that were expected errors held steady at 89.70%. The percentage of expected errors that received a diagnostic held steady at 86.99%. The number of fully passing files held steady at 91/134.

@astral-sh-bot

astral-sh-bot Bot commented May 23, 2026

Copy link
Copy Markdown

Memory usage report

Summary

Project Old New Diff Outcome
prefect 722.40MB 722.82MB +0.06% (421.90kB)
sphinx 267.86MB 268.00MB +0.05% (139.97kB)
trio 113.90MB 113.98MB +0.07% (82.89kB)
flake8 46.86MB 46.88MB +0.04% (19.63kB)

Significant changes

Click to expand detailed breakdown

prefect

Name Old New Diff Outcome
Type<'db>::class_member_with_policy_ 18.13MB 18.28MB +0.82% (151.93kB)
Type<'db>::member_lookup_with_policy_ 17.02MB 17.12MB +0.60% (104.20kB)
infer_scope_types_impl 50.72MB 50.78MB +0.11% (59.07kB)
StaticClassLiteral<'db>::implicit_attribute_inner_ 6.96MB 7.02MB +0.81% (57.54kB)
StaticClassLiteral<'db>::implicit_attribute_inner_::interned_arguments 5.65MB 5.70MB +0.85% (49.41kB)
FunctionType 10.01MB 10.01MB +0.04% (3.98kB)
infer_expression_type_impl 8.21MB 8.21MB -0.04% (3.48kB)
infer_expression_types_impl 60.24MB 60.24MB -0.00% (1.21kB)
Type<'db>::apply_specialization_ 3.54MB 3.54MB +0.03% (1.20kB)
infer_definition_types 89.64MB 89.64MB -0.00% (752.00B)
Type<'db>::apply_specialization_::interned_arguments 3.54MB 3.54MB +0.01% (320.00B)
CallableType 2.79MB 2.79MB -0.01% (304.00B)
all_narrowing_constraints_for_expression 6.78MB 6.78MB -0.00% (268.00B)
all_negative_narrowing_constraints_for_expression 6.61MB 6.61MB -0.00% (268.00B)
is_redundant_with_impl::interned_arguments 2.36MB 2.36MB +0.01% (176.00B)
... 7 more

sphinx

Name Old New Diff Outcome
Type<'db>::class_member_with_policy_ 7.90MB 7.94MB +0.52% (41.70kB)
Type<'db>::member_lookup_with_policy_ 7.24MB 7.28MB +0.50% (37.15kB)
StaticClassLiteral<'db>::implicit_attribute_inner_ 2.37MB 2.39MB +0.82% (20.02kB)
StaticClassLiteral<'db>::implicit_attribute_inner_::interned_arguments 1.93MB 1.95MB +0.87% (17.16kB)
infer_scope_types_impl 13.46MB 13.47MB +0.12% (16.29kB)
FunctionType 3.56MB 3.56MB +0.12% (4.20kB)
infer_definition_types 23.94MB 23.94MB +0.00% (1.02kB)
infer_expression_types_impl 21.94MB 21.94MB +0.00% (956.00B)
Type<'db>::apply_specialization_ 1.74MB 1.74MB +0.04% (696.00B)
Type<'db>::apply_specialization_::interned_arguments 1.79MB 1.79MB +0.01% (240.00B)
OverloadLiteral 1.00MB 1.00MB +0.02% (224.00B)
infer_expression_type_impl 3.01MB 3.01MB +0.01% (160.00B)
all_narrowing_constraints_for_expression 2.60MB 2.60MB +0.00% (116.00B)
all_negative_narrowing_constraints_for_expression 2.54MB 2.54MB +0.00% (116.00B)

trio

Name Old New Diff Outcome
Type<'db>::class_member_with_policy_ 1.98MB 2.00MB +1.17% (23.72kB)
FunctionType 1.56MB 1.57MB +1.03% (16.44kB)
StaticClassLiteral<'db>::implicit_attribute_inner_ 723.10kB 738.74kB +2.16% (15.64kB)
StaticClassLiteral<'db>::implicit_attribute_inner_::interned_arguments 577.03kB 590.44kB +2.32% (13.41kB)
Type<'db>::member_lookup_with_policy_ 1.93MB 1.93MB +0.34% (6.76kB)
infer_scope_types_impl 4.13MB 4.14MB +0.12% (5.02kB)
infer_definition_types 7.69MB 7.69MB +0.01% (992.00B)
Type<'db>::apply_specialization_ 623.86kB 624.72kB +0.14% (888.00B)
Type<'db>::try_call_dunder_get_ 1.31MB 1.31MB -0.03% (448.00B)
Type<'db>::apply_specialization_::interned_arguments 629.84kB 630.08kB +0.04% (240.00B)
OverloadLiteral 440.97kB 441.19kB +0.05% (224.00B)
Type<'db>::try_call_dunder_get_::interned_arguments 339.22kB 339.12kB -0.03% (104.00B)
place_by_id 546.80kB 546.89kB +0.02% (88.00B)
place_by_id::interned_arguments 408.09kB 408.16kB +0.02% (72.00B)

flake8

Name Old New Diff Outcome
Type<'db>::class_member_with_policy_ 589.82kB 594.64kB +0.82% (4.83kB)
StaticClassLiteral<'db>::implicit_attribute_inner_ 308.43kB 311.75kB +1.08% (3.32kB)
Type<'db>::member_lookup_with_policy_ 570.78kB 573.92kB +0.55% (3.14kB)
StaticClassLiteral<'db>::implicit_attribute_inner_::interned_arguments 254.16kB 256.88kB +1.07% (2.72kB)
FunctionType 491.39kB 492.88kB +0.30% (1.48kB)
infer_definition_types 1.84MB 1.84MB +0.08% (1.48kB)
infer_scope_types_impl 870.38kB 871.64kB +0.14% (1.25kB)
OverloadLiteral 120.40kB 120.72kB +0.27% (336.00B)
function_known_decorators 155.84kB 156.08kB +0.15% (244.00B)
place_by_id 140.75kB 140.93kB +0.12% (176.00B)
infer_expression_types_impl 1.09MB 1.09MB +0.01% (156.00B)
infer_expression_type_impl 115.93kB 116.08kB +0.13% (156.00B)
place_by_id::interned_arguments 105.47kB 105.61kB +0.13% (144.00B)
Type<'db>::apply_specialization_ 211.61kB 211.75kB +0.06% (140.00B)
Type<'db>::apply_specialization_::interned_arguments 221.56kB 221.64kB +0.04% (80.00B)

@astral-sh-bot

astral-sh-bot Bot commented May 23, 2026

Copy link
Copy Markdown

ecosystem-analyzer results

Lint rule Added Removed Changed
invalid-assignment 0 0 1
Total 0 0 1

Raw diff:

core (https://github.com/home-assistant/core)
- homeassistant/components/bluetooth/passive_update_processor.py:97:30 error[invalid-assignment] Object of type `object` is not assignable to `type[EntityDescription]`
+ homeassistant/components/bluetooth/passive_update_processor.py:97:30 error[invalid-assignment] Object of type `type` is not assignable to `type[EntityDescription]`

Full report with detailed diff (timing results)

@charliermarsh charliermarsh marked this pull request as ready for review May 23, 2026 16:42
@charliermarsh charliermarsh assigned sharkdp and unassigned carljm May 23, 2026
@charliermarsh charliermarsh marked this pull request as draft May 23, 2026 17:15
self.extend_with_class_members(db, ty, metaclass.class_literal(db));
if let Some((metaclass, _)) = metaclass.static_class_literal(db) {
self.extend_with_instance_members(db, ty, metaclass);
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This here adds instance members defined during metaclass initialization, like:

class Meta(type):
    def __init__(cls, ...):
        cls.generated = 1

(Which wasn't happening before.)

@charliermarsh charliermarsh marked this pull request as ready for review May 23, 2026 17:46
Comment thread crates/ty_python_semantic/resources/mdtest/attributes.md
@charliermarsh charliermarsh force-pushed the charlie/codex-metaclass-init-class-attributes branch from d9067e7 to 59ca3d2 Compare May 27, 2026 10:06
@charliermarsh charliermarsh requested a review from sharkdp May 27, 2026 10:42
@codspeed-hq

codspeed-hq Bot commented May 27, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 65 untouched benchmarks
⏩ 60 skipped benchmarks1


Comparing charlie/codex-metaclass-init-class-attributes (69ac292) with main (2428fca)

Open in CodSpeed

Footnotes

  1. 60 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@sharkdp sharkdp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thank you

Comment thread crates/ty_python_semantic/resources/mdtest/attributes.md
Comment thread crates/ty_python_semantic/src/types.rs Outdated
Comment thread crates/ty_python_semantic/src/types/list_members.rs Outdated
Comment thread crates/ty_python_semantic/src/types.rs Outdated
);

if let Some(metaclass_instance) = self.to_meta_type(db).to_instance(db) {
let metaclass_attr = metaclass_instance.instance_member(db, name);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hm, interesting. I guess I never thought about it that way, but I guess it all makes sense: this works because the cls in the metaclasses' __init__ is recognized as the self attribute.

class Meta(type):
    def __init__(cls, name: str, bases: tuple[type, ...], namespace: dict[str, object]) -> None:
        cls.attr: int = 1

Comment thread crates/ty_python_semantic/src/types.rs Outdated
..
})
) {
class_attr.or_fall_back_to(db, || metaclass_attr)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can the fallback ever be relevant here if we match on Place::Defined above? If not, can we simplify this by skipping the metaclass instance member lookup entirely?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I think it is possible to be Place::Defined but Definedness::PossiblyUndefined, as in;

class Meta(type):
    def __init__(cls, ...) -> None:
        cls.attr: int | str = 1

def _(flag: bool):
    class C(metaclass=Meta):
        if flag:
            attr: str = "class value"

    reveal_type(C.attr)  # int | str

Comment thread crates/ty_python_semantic/src/types.rs Outdated
metaclass_attr.or_fall_back_to(db, || class_attr)
}
} else {
class_attr

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

What if the metaclass_attr is possibly defined? Shouldn't we create a union type in that case?

@charliermarsh charliermarsh force-pushed the charlie/codex-metaclass-init-class-attributes branch from 53f9e7f to fa21e9f Compare May 27, 2026 14:04
@charliermarsh charliermarsh marked this pull request as draft May 27, 2026 14:28
@charliermarsh charliermarsh force-pushed the charlie/codex-metaclass-init-class-attributes branch from fa21e9f to 64433d5 Compare May 27, 2026 14:34
@charliermarsh charliermarsh force-pushed the charlie/codex-metaclass-init-class-attributes branch from 64433d5 to 69ac292 Compare May 27, 2026 14:38
@charliermarsh charliermarsh marked this pull request as ready for review May 27, 2026 14:50
@astral-sh-bot astral-sh-bot Bot requested a review from sharkdp May 27, 2026 14:50
@charliermarsh charliermarsh marked this pull request as draft May 27, 2026 14:50
@charliermarsh charliermarsh marked this pull request as ready for review May 27, 2026 15:02
@charliermarsh charliermarsh merged commit c8cd59f into main May 27, 2026
59 of 60 checks passed
@charliermarsh charliermarsh deleted the charlie/codex-metaclass-init-class-attributes branch May 27, 2026 15:02
anishgirianish pushed a commit to anishgirianish/ruff that referenced this pull request May 28, 2026
…ral-sh#25342)

## Summary

Prior to this change, we didn't recognize class-object attributes
populated by a metaclass during class initialization. For example:

```python
class Meta(type):
    def __init__(cls, name: str, bases: tuple[type, ...], namespace: dict[str, object]) -> None:
        cls.attr: int = 1

class C(metaclass=Meta): ...

reveal_type(C.attr)  # revealed: int
```

We now recognize attributes that a metaclass adds to a class while
creating it. If the metaclass overwrites an existing class-body value,
we use the overwritten value's type. If the class provides an annotation
for the generated attribute, we preserve that annotation as its public
type.

For example, if the class body initially gives attr a value, the
metaclass assignment happens later at runtime and overwrites it:

```python
class Meta(type):
    def __init__(cls, name, bases, namespace):
        cls.attr: int = 1

class C(metaclass=Meta):
    attr = "initial value"

reveal_type(C.attr)  # int
```

Closes astral-sh/ty#1138.
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.

Detect class attributes set by metaclasses __init__ method

3 participants