Skip to content

Commit 21cc1c7

Browse files
authored
Improve handling of attribute access on class objects (#14988)
Fixes #14056 #14056 was originally reported as a mypyc issue (because that's how it presented itself to the user), but the underlying bug is really a bug to do with how mypy understands metaclasses. On mypy `master`: ```py class Meta(type): bar: str class Foo(metaclass=Meta): bar: int reveal_type(Foo().bar) # Revealed type is int (correct!) reveal_type(Foo.bar) # Revealed type is int, but should be str ``` This PR fixes that incorrect behaviour. Since this is really a mypy bug rather than a mypyc bug, I haven't added a mypyc test, but I'm happy to if that would be useful. (I'll need some guidance as to exactly where it should go -- I don't know much about mypyc internals!)
1 parent db5b5af commit 21cc1c7

File tree

4 files changed

+76
-5
lines changed

4 files changed

+76
-5
lines changed

Diff for: mypy/checkmember.py

+26-4
Original file line numberDiff line numberDiff line change
@@ -388,7 +388,7 @@ def analyze_type_callable_member_access(name: str, typ: FunctionLike, mx: Member
388388
# See https://github.com/python/mypy/pull/1787 for more info.
389389
# TODO: do not rely on same type variables being present in all constructor overloads.
390390
result = analyze_class_attribute_access(
391-
ret_type, name, mx, original_vars=typ.items[0].variables
391+
ret_type, name, mx, original_vars=typ.items[0].variables, mcs_fallback=typ.fallback
392392
)
393393
if result:
394394
return result
@@ -434,17 +434,21 @@ def analyze_type_type_member_access(
434434
if isinstance(typ.item.item, Instance):
435435
item = typ.item.item.type.metaclass_type
436436
ignore_messages = False
437+
438+
if item is not None:
439+
fallback = item.type.metaclass_type or fallback
440+
437441
if item and not mx.is_operator:
438442
# See comment above for why operators are skipped
439-
result = analyze_class_attribute_access(item, name, mx, override_info)
443+
result = analyze_class_attribute_access(
444+
item, name, mx, mcs_fallback=fallback, override_info=override_info
445+
)
440446
if result:
441447
if not (isinstance(get_proper_type(result), AnyType) and item.type.fallback_to_any):
442448
return result
443449
else:
444450
# We don't want errors on metaclass lookup for classes with Any fallback
445451
ignore_messages = True
446-
if item is not None:
447-
fallback = item.type.metaclass_type or fallback
448452

449453
with mx.msg.filter_errors(filter_errors=ignore_messages):
450454
return _analyze_member_access(name, fallback, mx, override_info)
@@ -893,6 +897,8 @@ def analyze_class_attribute_access(
893897
itype: Instance,
894898
name: str,
895899
mx: MemberContext,
900+
*,
901+
mcs_fallback: Instance,
896902
override_info: TypeInfo | None = None,
897903
original_vars: Sequence[TypeVarLikeType] | None = None,
898904
) -> Type | None:
@@ -919,6 +925,22 @@ def analyze_class_attribute_access(
919925
return apply_class_attr_hook(mx, hook, AnyType(TypeOfAny.special_form))
920926
return None
921927

928+
if (
929+
isinstance(node.node, Var)
930+
and not node.node.is_classvar
931+
and not hook
932+
and mcs_fallback.type.get(name)
933+
):
934+
# If the same attribute is declared on the metaclass and the class but with different types,
935+
# and the attribute on the class is not a ClassVar,
936+
# the type of the attribute on the metaclass should take priority
937+
# over the type of the attribute on the class,
938+
# when the attribute is being accessed from the class object itself.
939+
#
940+
# Return `None` here to signify that the name should be looked up
941+
# on the class object itself rather than the instance.
942+
return None
943+
922944
is_decorated = isinstance(node.node, Decorator)
923945
is_method = is_decorated or isinstance(node.node, FuncBase)
924946
if mx.is_lvalue:

Diff for: test-data/unit/check-class-namedtuple.test

+4-1
Original file line numberDiff line numberDiff line change
@@ -325,7 +325,10 @@ class X(NamedTuple):
325325
reveal_type(X._fields) # N: Revealed type is "Tuple[builtins.str, builtins.str]"
326326
reveal_type(X._field_types) # N: Revealed type is "builtins.dict[builtins.str, Any]"
327327
reveal_type(X._field_defaults) # N: Revealed type is "builtins.dict[builtins.str, Any]"
328-
reveal_type(X.__annotations__) # N: Revealed type is "builtins.dict[builtins.str, Any]"
328+
329+
# In typeshed's stub for builtins.pyi, __annotations__ is `dict[str, Any]`,
330+
# but it's inferred as `Mapping[str, object]` here due to the fixture we're using
331+
reveal_type(X.__annotations__) # N: Revealed type is "typing.Mapping[builtins.str, builtins.object]"
329332

330333
[builtins fixtures/dict.pyi]
331334

Diff for: test-data/unit/check-classes.test

+33
Original file line numberDiff line numberDiff line change
@@ -4540,6 +4540,39 @@ def f(TA: Type[A]):
45404540
reveal_type(TA) # N: Revealed type is "Type[__main__.A]"
45414541
reveal_type(TA.x) # N: Revealed type is "builtins.int"
45424542

4543+
[case testMetaclassConflictingInstanceVars]
4544+
from typing import ClassVar
4545+
4546+
class Meta(type):
4547+
foo: int
4548+
bar: int
4549+
eggs: ClassVar[int] = 42
4550+
spam: ClassVar[int] = 42
4551+
4552+
class Foo(metaclass=Meta):
4553+
foo: str
4554+
bar: ClassVar[str] = 'bar'
4555+
eggs: str
4556+
spam: ClassVar[str] = 'spam'
4557+
4558+
reveal_type(Foo.foo) # N: Revealed type is "builtins.int"
4559+
reveal_type(Foo.bar) # N: Revealed type is "builtins.str"
4560+
reveal_type(Foo.eggs) # N: Revealed type is "builtins.int"
4561+
reveal_type(Foo.spam) # N: Revealed type is "builtins.str"
4562+
4563+
class MetaSub(Meta): ...
4564+
4565+
class Bar(metaclass=MetaSub):
4566+
foo: str
4567+
bar: ClassVar[str] = 'bar'
4568+
eggs: str
4569+
spam: ClassVar[str] = 'spam'
4570+
4571+
reveal_type(Bar.foo) # N: Revealed type is "builtins.int"
4572+
reveal_type(Bar.bar) # N: Revealed type is "builtins.str"
4573+
reveal_type(Bar.eggs) # N: Revealed type is "builtins.int"
4574+
reveal_type(Bar.spam) # N: Revealed type is "builtins.str"
4575+
45434576
[case testSubclassMetaclass]
45444577
class M1(type):
45454578
x = 0

Diff for: test-data/unit/pythoneval.test

+13
Original file line numberDiff line numberDiff line change
@@ -1987,6 +1987,19 @@ def good9(foo1: Foo[Concatenate[int, P]], foo2: Foo[[int, str, bytes]], *args: P
19871987
[out]
19881988
_testStrictEqualitywithParamSpec.py:11: error: Non-overlapping equality check (left operand type: "Foo[[int]]", right operand type: "Bar[[int]]")
19891989

1990+
[case testInferenceOfDunderDictOnClassObjects]
1991+
class Foo: ...
1992+
reveal_type(Foo.__dict__)
1993+
reveal_type(Foo().__dict__)
1994+
Foo.__dict__ = {}
1995+
Foo().__dict__ = {}
1996+
1997+
[out]
1998+
_testInferenceOfDunderDictOnClassObjects.py:2: note: Revealed type is "types.MappingProxyType[builtins.str, Any]"
1999+
_testInferenceOfDunderDictOnClassObjects.py:3: note: Revealed type is "builtins.dict[builtins.str, Any]"
2000+
_testInferenceOfDunderDictOnClassObjects.py:4: error: Property "__dict__" defined in "type" is read-only
2001+
_testInferenceOfDunderDictOnClassObjects.py:4: error: Incompatible types in assignment (expression has type "Dict[<nothing>, <nothing>]", variable has type "MappingProxyType[str, Any]")
2002+
19902003
[case testTypeVarTuple]
19912004
# flags: --enable-incomplete-feature=TypeVarTuple --enable-incomplete-feature=Unpack --python-version=3.11
19922005
from typing import Any, Callable, Unpack, TypeVarTuple

0 commit comments

Comments
 (0)