Skip to content

Stubtest: verify stub methods or properties are decorated with @final if they are decorated with @final at runtime #14951

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Mar 24, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 41 additions & 13 deletions mypy/stubtest.py
Original file line number Diff line number Diff line change
Expand Up @@ -529,8 +529,21 @@ def verify_typeinfo(
yield from verify(stub_to_verify, runtime_attr, object_path + [entry])


def _static_lookup_runtime(object_path: list[str]) -> MaybeMissing[Any]:
static_runtime = importlib.import_module(object_path[0])
for entry in object_path[1:]:
try:
static_runtime = inspect.getattr_static(static_runtime, entry)
except AttributeError:
# This can happen with mangled names, ignore for now.
# TODO: pass more information about ancestors of nodes/objects to verify, so we don't
# have to do this hacky lookup. Would be useful in several places.
return MISSING
return static_runtime


def _verify_static_class_methods(
stub: nodes.FuncBase, runtime: Any, object_path: list[str]
stub: nodes.FuncBase, runtime: Any, static_runtime: MaybeMissing[Any], object_path: list[str]
) -> Iterator[str]:
if stub.name in ("__new__", "__init_subclass__", "__class_getitem__"):
# Special cased by Python, so don't bother checking
Expand All @@ -545,16 +558,8 @@ def _verify_static_class_methods(
yield "stub is a classmethod but runtime is not"
return

# Look the object up statically, to avoid binding by the descriptor protocol
static_runtime = importlib.import_module(object_path[0])
for entry in object_path[1:]:
try:
static_runtime = inspect.getattr_static(static_runtime, entry)
except AttributeError:
# This can happen with mangled names, ignore for now.
# TODO: pass more information about ancestors of nodes/objects to verify, so we don't
# have to do this hacky lookup. Would be useful in a couple other places too.
return
if static_runtime is MISSING:
return

if isinstance(static_runtime, classmethod) and not stub.is_class:
yield "runtime is a classmethod but stub is not"
Expand Down Expand Up @@ -945,11 +950,16 @@ def verify_funcitem(
if not callable(runtime):
return

# Look the object up statically, to avoid binding by the descriptor protocol
static_runtime = _static_lookup_runtime(object_path)

if isinstance(stub, nodes.FuncDef):
for error_text in _verify_abstract_status(stub, runtime):
yield Error(object_path, error_text, stub, runtime)
for error_text in _verify_final_method(stub, runtime, static_runtime):
yield Error(object_path, error_text, stub, runtime)

for message in _verify_static_class_methods(stub, runtime, object_path):
for message in _verify_static_class_methods(stub, runtime, static_runtime, object_path):
yield Error(object_path, "is inconsistent, " + message, stub, runtime)

signature = safe_inspect_signature(runtime)
Expand Down Expand Up @@ -1063,9 +1073,15 @@ def verify_overloadedfuncdef(
for msg in _verify_abstract_status(first_part.func, runtime):
yield Error(object_path, msg, stub, runtime)

for message in _verify_static_class_methods(stub, runtime, object_path):
# Look the object up statically, to avoid binding by the descriptor protocol
static_runtime = _static_lookup_runtime(object_path)

for message in _verify_static_class_methods(stub, runtime, static_runtime, object_path):
yield Error(object_path, "is inconsistent, " + message, stub, runtime)

# TODO: Should call _verify_final_method here,
# but overloaded final methods in stubs cause a stubtest crash: see #14950

signature = safe_inspect_signature(runtime)
if not signature:
return
Expand Down Expand Up @@ -1126,6 +1142,7 @@ def verify_paramspecexpr(
def _verify_readonly_property(stub: nodes.Decorator, runtime: Any) -> Iterator[str]:
assert stub.func.is_property
if isinstance(runtime, property):
yield from _verify_final_method(stub.func, runtime.fget, MISSING)
return
if inspect.isdatadescriptor(runtime):
# It's enough like a property...
Expand Down Expand Up @@ -1154,6 +1171,17 @@ def _verify_abstract_status(stub: nodes.FuncDef, runtime: Any) -> Iterator[str]:
yield f"is inconsistent, runtime {item_type} is abstract but stub is not"


def _verify_final_method(
stub: nodes.FuncDef, runtime: Any, static_runtime: MaybeMissing[Any]
) -> Iterator[str]:
if stub.is_final:
return
if getattr(runtime, "__final__", False) or (
static_runtime is not MISSING and getattr(static_runtime, "__final__", False)
):
yield "is decorated with @final at runtime, but not in the stub"


def _resolve_funcitem_from_decorator(dec: nodes.OverloadPart) -> nodes.FuncItem | None:
"""Returns a FuncItem that corresponds to the output of the decorator.

Expand Down
220 changes: 219 additions & 1 deletion mypy/test/teststubtest.py
Original file line number Diff line number Diff line change
Expand Up @@ -1143,7 +1143,10 @@ def test_not_subclassable(self) -> Iterator[Case]:
def test_has_runtime_final_decorator(self) -> Iterator[Case]:
yield Case(
stub="from typing_extensions import final",
runtime="from typing_extensions import final",
runtime="""
import functools
from typing_extensions import final
""",
error=None,
)
yield Case(
Expand Down Expand Up @@ -1177,6 +1180,221 @@ class C: ...
""",
error="C",
)
yield Case(
stub="""
class D:
@final
def foo(self) -> None: ...
@final
@staticmethod
def bar() -> None: ...
@staticmethod
@final
def bar2() -> None: ...
@final
@classmethod
def baz(cls) -> None: ...
@classmethod
@final
def baz2(cls) -> None: ...
@property
@final
def eggs(self) -> int: ...
@final
@property
def eggs2(self) -> int: ...
@final
def ham(self, obj: int) -> int: ...
""",
runtime="""
class D:
@final
def foo(self): pass
@final
@staticmethod
def bar(): pass
@staticmethod
@final
def bar2(): pass
@final
@classmethod
def baz(cls): pass
@classmethod
@final
def baz2(cls): pass
@property
@final
def eggs(self): return 42
@final
@property
def eggs2(self): pass
@final
@functools.lru_cache()
def ham(self, obj): return obj * 2
""",
error=None,
)
# Stub methods are allowed to have @final even if the runtime doesn't...
yield Case(
stub="""
class E:
@final
def foo(self) -> None: ...
@final
@staticmethod
def bar() -> None: ...
@staticmethod
@final
def bar2() -> None: ...
@final
@classmethod
def baz(cls) -> None: ...
@classmethod
@final
def baz2(cls) -> None: ...
@property
@final
def eggs(self) -> int: ...
@final
@property
def eggs2(self) -> int: ...
@final
def ham(self, obj: int) -> int: ...
""",
runtime="""
class E:
def foo(self): pass
@staticmethod
def bar(): pass
@staticmethod
def bar2(): pass
@classmethod
def baz(cls): pass
@classmethod
def baz2(cls): pass
@property
def eggs(self): return 42
@property
def eggs2(self): return 42
@functools.lru_cache()
def ham(self, obj): return obj * 2
""",
error=None,
)
# ...But if the runtime has @final, the stub must have it as well
yield Case(
stub="""
class F:
def foo(self) -> None: ...
""",
runtime="""
class F:
@final
def foo(self): pass
""",
error="F.foo",
)
yield Case(
stub="""
class G:
@staticmethod
def foo() -> None: ...
""",
runtime="""
class G:
@final
@staticmethod
def foo(): pass
""",
error="G.foo",
)
yield Case(
stub="""
class H:
@staticmethod
def foo() -> None: ...
""",
runtime="""
class H:
@staticmethod
@final
def foo(): pass
""",
error="H.foo",
)
yield Case(
stub="""
class I:
@classmethod
def foo(cls) -> None: ...
""",
runtime="""
class I:
@final
@classmethod
def foo(cls): pass
""",
error="I.foo",
)
yield Case(
stub="""
class J:
@classmethod
def foo(cls) -> None: ...
""",
runtime="""
class J:
@classmethod
@final
def foo(cls): pass
""",
error="J.foo",
)
yield Case(
stub="""
class K:
@property
def foo(self) -> int: ...
""",
runtime="""
class K:
@property
@final
def foo(self): return 42
""",
error="K.foo",
)
# This test wouldn't pass,
# because the runtime can't set __final__ on instances of builtins.property,
# so stubtest has non way of knowing that the runtime was decorated with @final:
#
# yield Case(
# stub="""
# class K2:
# @property
# def foo(self) -> int: ...
# """,
# runtime="""
# class K2:
# @final
# @property
# def foo(self): return 42
# """,
# error="K2.foo",
# )
yield Case(
stub="""
class L:
def foo(self, obj: int) -> int: ...
""",
runtime="""
class L:
@final
@functools.lru_cache()
def foo(self, obj): return obj * 2
""",
error="L.foo",
)

@collect_cases
def test_name_mangling(self) -> Iterator[Case]:
Expand Down