Skip to content

Commit c8cd59f

Browse files
[ty] Infer class attributes assigned by metaclass initialization (#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.
1 parent 2428fca commit c8cd59f

7 files changed

Lines changed: 424 additions & 29 deletions

File tree

crates/ty_python_semantic/resources/mdtest/attributes.md

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1033,6 +1033,152 @@ class C1(metaclass=Meta1): ...
10331033
reveal_type(C1.attr) # revealed: Literal["metaclass value"]
10341034
```
10351035

1036+
Assignments in instance methods of a metaclass are also attributes on its class-object instances. In
1037+
particular, a metaclass `__init__` assignment happens after the initial class namespace has been
1038+
converted into a class object, so it shadows an attribute from that namespace:
1039+
1040+
```py
1041+
class InitializingMeta(type):
1042+
def __init__(cls, name: str, bases: tuple[type, ...], namespace: dict[str, object]) -> None:
1043+
cls.attr: int = 1
1044+
1045+
class CCreated(metaclass=InitializingMeta): ...
1046+
1047+
reveal_type(CCreated.attr) # revealed: int
1048+
1049+
class CInitialized(metaclass=InitializingMeta):
1050+
# error: [invalid-assignment] "Object of type `Literal["initial class value"]` is not assignable to attribute `attr` of type `int`"
1051+
attr = "initial class value"
1052+
1053+
reveal_type(CInitialized.attr) # revealed: int
1054+
CInitialized.attr = 2
1055+
# error: [invalid-assignment] "Object of type `Literal["invalid"]` is not assignable to attribute `attr` of type `int`"
1056+
CInitialized.attr = "invalid"
1057+
1058+
class LiteralInitializingMeta(type):
1059+
def __init__(cls, name: str, bases: tuple[type, ...], namespace: dict[str, object]) -> None:
1060+
cls.attr: Literal[1] = 1
1061+
1062+
class CAugmentedInitialized(metaclass=LiteralInitializingMeta):
1063+
attr = 1
1064+
attr += 1 # error: [invalid-assignment]
1065+
1066+
class CLoopInitialized(metaclass=LiteralInitializingMeta):
1067+
for attr in ("invalid",): # error: [invalid-assignment]
1068+
pass
1069+
1070+
class CNamedInitialized(metaclass=LiteralInitializingMeta):
1071+
if attr := "invalid": # error: [invalid-assignment]
1072+
pass
1073+
1074+
class InvalidContextManager:
1075+
def __enter__(self) -> str:
1076+
return "invalid"
1077+
1078+
def __exit__(self, exc_type: object, exc_value: object, traceback: object) -> None:
1079+
pass
1080+
1081+
class CWithInitialized(metaclass=LiteralInitializingMeta):
1082+
with InvalidContextManager() as attr: # error: [invalid-assignment]
1083+
pass
1084+
1085+
class CAnnotatedInitialized(metaclass=InitializingMeta):
1086+
attr: str = "invalid" # error: [invalid-assignment]
1087+
1088+
class CMethodInitialized(metaclass=InitializingMeta):
1089+
# error: [invalid-assignment]
1090+
def attr(self) -> None:
1091+
pass
1092+
1093+
class CNestedClassInitialized(metaclass=InitializingMeta):
1094+
# error: [invalid-assignment]
1095+
class attr:
1096+
pass
1097+
1098+
class CImportInitialized(metaclass=InitializingMeta):
1099+
import sys as attr # error: [invalid-assignment]
1100+
1101+
# Exception-handler targets are removed from the namespace on leaving the handler.
1102+
class CExceptionBindingCleared(metaclass=InitializingMeta):
1103+
try:
1104+
raise RuntimeError
1105+
except RuntimeError as attr:
1106+
pass
1107+
1108+
class BroadInitializingMeta(type):
1109+
def __init__(cls, name: str, bases: tuple[type, ...], namespace: dict[str, object]) -> None:
1110+
value: object = object()
1111+
cls.attr = value
1112+
1113+
class CDeclared(metaclass=BroadInitializingMeta):
1114+
attr: str # error: [invalid-assignment]
1115+
1116+
# A class-body declaration is a contract for an attribute populated by metaclass initialization.
1117+
reveal_type(CDeclared.attr) # revealed: str
1118+
1119+
class DeclaredBroadInitializingMeta(type):
1120+
def __init__(cls, name: str, bases: tuple[type, ...], namespace: dict[str, object]) -> None:
1121+
cls.attr: object = object()
1122+
1123+
class CDeclaredAgainstDeclaredMeta(metaclass=DeclaredBroadInitializingMeta):
1124+
attr: str # error: [invalid-assignment]
1125+
1126+
reveal_type(CDeclaredAgainstDeclaredMeta.attr) # revealed: str
1127+
1128+
class CompatibleInitializingMeta(type):
1129+
def __init__(cls, name: str, bases: tuple[type, ...], namespace: dict[str, object]) -> None:
1130+
cls.attr: int | str = 1
1131+
1132+
def _(flag: bool):
1133+
class CConditionallyDeclared(metaclass=CompatibleInitializingMeta):
1134+
if flag:
1135+
attr: str = "class value" # error: [invalid-assignment]
1136+
1137+
# On paths without the class-body value, the metaclass-populated value remains available.
1138+
reveal_type(CConditionallyDeclared.attr) # revealed: int | str
1139+
1140+
class ReplacingMethodsMeta(type):
1141+
def __init__(cls, name: str, bases: tuple[type, ...], namespace: dict[str, object]) -> None:
1142+
cls.factory = lambda: object()
1143+
cls.arguments = lambda: ()
1144+
1145+
class MethodsReplacedAtConstruction(metaclass=ReplacingMethodsMeta):
1146+
def factory(self, value: int) -> str:
1147+
return ""
1148+
1149+
def arguments(self, value: int) -> str:
1150+
return ""
1151+
1152+
class TypedReplacingMethodsMeta(type):
1153+
def __init__(cls, name: str, bases: tuple[type, ...], namespace: dict[str, object]) -> None:
1154+
cls.factory: object = object()
1155+
1156+
class MethodReplacedByTypedMeta(metaclass=TypedReplacingMethodsMeta):
1157+
def factory(self) -> str:
1158+
return ""
1159+
1160+
class PopulatingMetaclass(type):
1161+
def __init__(cls, name: str, bases: tuple[type, ...], namespace: dict[str, object]) -> None:
1162+
cls.populated_on_meta: int = 1
1163+
1164+
class PopulatedMeta(type, metaclass=PopulatingMetaclass): ...
1165+
class ConstructedByPopulatedMeta(metaclass=PopulatedMeta): ...
1166+
1167+
reveal_type(PopulatedMeta.populated_on_meta) # revealed: int
1168+
reveal_type(ConstructedByPopulatedMeta.populated_on_meta) # revealed: int
1169+
1170+
class DerivedInitializingMeta(type):
1171+
def __init__(cls, name: str, bases: tuple[type, ...], namespace: dict[str, object]) -> None:
1172+
cls.inherited_attr: int = 1
1173+
1174+
class DeclaringBase:
1175+
inherited_attr: str
1176+
1177+
class InitializedDerived(DeclaringBase, metaclass=DerivedInitializingMeta): ...
1178+
1179+
reveal_type(InitializedDerived.inherited_attr) # revealed: int
1180+
```
1181+
10361182
However, the metaclass attribute only takes precedence over a class-level attribute if it is a data
10371183
descriptor. If it is a non-data descriptor or a normal attribute, the class-level attribute is used
10381184
instead (see the [descriptor protocol tests] for data/non-data descriptor attributes):

crates/ty_python_semantic/resources/mdtest/ide_support/all_members.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,20 @@ def _[T: D](x: type[T]):
193193
static_assert(has_member(x, "meta_attr"))
194194
static_assert(has_member(x, "base_attr"))
195195
static_assert(has_member(x, "class_attr"))
196+
197+
class InitializingMeta(type):
198+
def __init__(cls, name: str, bases: tuple[type, ...], namespace: dict[str, object]) -> None:
199+
cls.initialized_attr: int = 1
200+
201+
class Initialized(metaclass=InitializingMeta): ...
202+
203+
static_assert(has_member(Initialized, "initialized_attr"))
204+
205+
def _(x: type[Initialized]):
206+
static_assert(has_member(x, "initialized_attr"))
207+
208+
def _[T: Initialized](x: type[T]):
209+
static_assert(has_member(x, "initialized_attr"))
196210
```
197211

198212
### Generic classes
@@ -216,12 +230,42 @@ Generic classes can also have metaclasses:
216230
class Meta(type):
217231
FOO = 42
218232

233+
def __init__(cls, name: str, bases: tuple[type, ...], namespace: dict[str, object]) -> None:
234+
cls.initialized_attr: int = 1
235+
219236
class E(Generic[T], metaclass=Meta): ...
220237

221238
static_assert(has_member(E[int], "FOO"))
239+
static_assert(has_member(E[int], "initialized_attr"))
222240

223241
def f(x: type[E[str]]):
224242
static_assert(has_member(x, "FOO"))
243+
static_assert(has_member(x, "initialized_attr"))
244+
```
245+
246+
### Generic metaclasses
247+
248+
```toml
249+
[environment]
250+
python-version = "3.13"
251+
```
252+
253+
```py
254+
from ty_extensions import has_member, static_assert
255+
256+
class InitializingMeta[T](type):
257+
def __init__(cls, name: str, bases: tuple[type, ...], namespace: dict[str, object]) -> None:
258+
cls.initialized_attr: int = 1
259+
260+
class Initialized(metaclass=InitializingMeta[int]): ...
261+
262+
static_assert(has_member(Initialized, "initialized_attr"))
263+
264+
def _(x: type[Initialized]):
265+
static_assert(has_member(x, "initialized_attr"))
266+
267+
def _[T: Initialized](x: type[T]):
268+
static_assert(has_member(x, "initialized_attr"))
225269
```
226270

227271
### `type[Any]` and `Any`

crates/ty_python_semantic/src/types.rs

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2566,6 +2566,10 @@ impl<'db> Type<'db> {
25662566
}
25672567
}
25682568

2569+
Type::ClassLiteral(_) | Type::GenericAlias(_) | Type::SubclassOf(_) => self
2570+
.to_meta_type(db)
2571+
.class_object_member(db, name.as_str(), policy),
2572+
25692573
_ => self
25702574
.to_meta_type(db)
25712575
.find_name_in_mro_with_policy(db, name.as_str(), policy)
@@ -2575,6 +2579,61 @@ impl<'db> Type<'db> {
25752579
}
25762580
}
25772581

2582+
/// Look up attributes stored in the namespace of a class object.
2583+
///
2584+
/// Besides attributes present in the class MRO, this includes attributes assigned to
2585+
/// instances of its metaclass. For example, `cls.x = ...` in `Meta.__init__` stores `x`
2586+
/// on each class object constructed by `Meta`.
2587+
fn class_object_member(
2588+
self,
2589+
db: &'db dyn Db,
2590+
name: &str,
2591+
policy: MemberLookupPolicy,
2592+
) -> PlaceAndQualifiers<'db> {
2593+
let class_attr = self.find_name_in_mro_with_policy(db, name, policy).expect(
2594+
"Calling `class_object_member` on class literals and subclass-of types should always find an MRO",
2595+
);
2596+
2597+
let own_class = match self {
2598+
Type::SubclassOf(subclass_of) => subclass_of.subclass_of().into_class(db),
2599+
_ => self.to_class_type(db),
2600+
};
2601+
let own_class_attr = own_class.map(|class| class.own_class_member(db, None, name).inner);
2602+
2603+
// A definitely-declared attribute in this class's own namespace is the contract for
2604+
// values populated by metaclass initialization, analogous to a declared instance
2605+
// attribute initialized in `__init__`. An inherited declaration does not mask a value
2606+
// that the metaclass stores directly on the newly constructed subclass.
2607+
let own_declaration_definedness = match own_class_attr {
2608+
Some(PlaceAndQualifiers {
2609+
place:
2610+
Place::Defined(DefinedPlace {
2611+
origin: TypeOrigin::Declared,
2612+
definedness,
2613+
..
2614+
}),
2615+
..
2616+
}) => Some(definedness),
2617+
_ => None,
2618+
};
2619+
if own_declaration_definedness == Some(Definedness::AlwaysDefined) {
2620+
return class_attr;
2621+
}
2622+
2623+
let Some(metaclass_instance) = self.to_meta_type(db).to_instance(db) else {
2624+
return class_attr;
2625+
};
2626+
let metaclass_attr = metaclass_instance.instance_member(db, name);
2627+
2628+
if own_declaration_definedness.is_some() {
2629+
// A conditionally-declared attribute is a contract only on paths where that
2630+
// declaration is present; the metaclass value is the fallback on other paths.
2631+
class_attr.or_fall_back_to(db, || metaclass_attr)
2632+
} else {
2633+
metaclass_attr.or_fall_back_to(db, || class_attr)
2634+
}
2635+
}
2636+
25782637
/// This function roughly corresponds to looking up an attribute in the `__dict__` of an object.
25792638
/// For instance-like types, this goes through the classes MRO and discovers attribute assignments
25802639
/// in methods, as well as class-body declarations that we consider to be evidence for the presence
@@ -3587,9 +3646,7 @@ impl<'db> Type<'db> {
35873646
.into();
35883647
}
35893648

3590-
let class_attr_plain = self.find_name_in_mro_with_policy(db, name_str, policy).expect(
3591-
"Calling `find_name_in_mro` on class literals and subclass-of types should always return `Some`",
3592-
);
3649+
let class_attr_plain = self.class_object_member(db, name_str, policy);
35933650

35943651
let self_instance = self
35953652
.to_instance(db)

crates/ty_python_semantic/src/types/diagnostic.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3887,7 +3887,7 @@ pub(super) fn report_invalid_assignment<'db>(
38873887

38883888
pub(super) fn report_invalid_attribute_assignment(
38893889
context: &InferContext,
3890-
node: AnyNodeRef,
3890+
range: TextRange,
38913891
target_ty: Type,
38923892
source_ty: Type,
38933893
attribute_name: &'_ str,
@@ -3899,7 +3899,7 @@ pub(super) fn report_invalid_attribute_assignment(
38993899

39003900
let Some(mut diag) = report_invalid_assignment_with_message(
39013901
context,
3902-
node,
3902+
range,
39033903
target_ty,
39043904
format_args!(
39053905
"Object of type `{}` is not assignable to attribute `{attribute_name}` of type `{}`",

crates/ty_python_semantic/src/types/infer/builder.rs

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2364,7 +2364,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
23642364
if !assignable && emit_diagnostics {
23652365
report_invalid_attribute_assignment(
23662366
&builder.context,
2367-
target.into(),
2367+
target.range(),
23682368
attr_ty,
23692369
value_ty,
23702370
attribute,
@@ -3049,7 +3049,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
30493049
if emit_diagnostics {
30503050
report_invalid_attribute_assignment(
30513051
&self.context,
3052-
target.into(),
3052+
target.range(),
30533053
attr_ty,
30543054
value_ty,
30553055
attribute,
@@ -3318,9 +3318,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
33183318
| Type::TypedDict(_)
33193319
| Type::NewTypeInstance(_) => object_ty.instance_member(db, attribute),
33203320
Type::ClassLiteral(..) | Type::GenericAlias(..) | Type::SubclassOf(..) => {
3321-
object_ty.find_name_in_mro(db, attribute).expect(
3322-
"called on Type::ClassLiteral, Type::GenericAlias, or Type::SubclassOf",
3323-
)
3321+
object_ty.class_object_member(db, attribute, MemberLookupPolicy::default())
33243322
}
33253323
Type::Union(..)
33263324
| Type::Intersection(..)

0 commit comments

Comments
 (0)