Skip to content

Commit 34774df

Browse files
Reduce more uninhabited intersections to Never (#43)
1 parent bf73de0 commit 34774df

File tree

6 files changed

+156
-5
lines changed

6 files changed

+156
-5
lines changed

docs/changelog.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
- Reduce more uninhabited intersections to `Never`
6+
37
## Version 0.2.0 (June 26, 2025)
48

59
- Fix crash on class definition keyword args when the `no_implicit_any` error

pycroscope/checker.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,11 @@ def _build_type_object(self, typ: Union[type, super, str]) -> TypeObject:
151151
else:
152152
protocol_members = set()
153153
return TypeObject(
154-
typ, bases, is_protocol=is_protocol, protocol_members=protocol_members
154+
typ,
155+
bases,
156+
is_protocol=is_protocol,
157+
protocol_members=protocol_members,
158+
is_final=self.ts_finder.is_final(typ),
155159
)
156160
elif isinstance(typ, super):
157161
return TypeObject(typ, self.get_additional_bases(typ))
@@ -181,7 +185,8 @@ def _build_type_object(self, typ: Union[type, super, str]) -> TypeObject:
181185
typ, additional_bases, is_protocol=True, protocol_members=members
182186
)
183187

184-
return TypeObject(typ, additional_bases)
188+
is_final = self.ts_finder.is_final(typ)
189+
return TypeObject(typ, additional_bases, is_final=is_final)
185190

186191
def _get_recursive_typeshed_bases(
187192
self, typ: Union[type, str]

pycroscope/relations.py

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99

1010
import collections.abc
1111
import enum
12+
import struct
13+
import sys
1214
from collections.abc import Iterable, Iterator, Sequence
1315
from dataclasses import dataclass, replace
1416
from types import FunctionType
@@ -19,7 +21,7 @@
1921
import pycroscope
2022
from pycroscope.analysis_lib import Sentinel
2123
from pycroscope.find_unused import used
22-
from pycroscope.safe import safe_equals, safe_isinstance
24+
from pycroscope.safe import safe_equals, safe_isinstance, safe_issubclass
2325
from pycroscope.typevar import resolve_bounds_map
2426
from pycroscope.value import (
2527
NO_RETURN_VALUE,
@@ -1374,10 +1376,96 @@ def _intersect_typed(
13741376
) -> TypeOrIrreducible:
13751377
if isinstance(left, TypedDictValue) and isinstance(right, TypedDictValue):
13761378
return _intersect_typeddict(left, right, ctx)
1377-
# TODO: Consider more options
1379+
1380+
# If either type is final, the intersection reduces to Never unless the final class
1381+
# is a subclass of the other class. In that case, we treat the intersection as irreducible.
1382+
# Note that we would have already reduced intersections where the *types* are subtypes;
1383+
# the irreducible case can happen in certain situations involving generics.
1384+
left_tobj = left.get_type_object(ctx)
1385+
right_tobj = right.get_type_object(ctx)
1386+
if left_tobj.is_final and not left_tobj.is_assignable_to_type_object(right_tobj):
1387+
return NO_RETURN_VALUE
1388+
if right_tobj.is_final and not right_tobj.is_assignable_to_type_object(left_tobj):
1389+
return NO_RETURN_VALUE
1390+
1391+
# If both types are nominal and are real (non-synthetic) types, we can mirror CPython's logic
1392+
# to check whether a child class can exist at runtime.
1393+
if (
1394+
not left_tobj.is_protocol
1395+
and not right_tobj.is_protocol
1396+
and isinstance(left_tobj.typ, type)
1397+
and isinstance(right_tobj.typ, type)
1398+
and not _can_nominal_types_intersect(left_tobj.typ, right_tobj.typ)
1399+
):
1400+
return NO_RETURN_VALUE
1401+
1402+
# TODO: Consider more options for reducing the intersection:
1403+
# - Certain cases involving incompatible metaclasses can return to Never
1404+
# - Possibly some cases with attributes of incompatible types (possibly debatable)
1405+
# - Cases involving SequenceValue, DictIncompleteValue, and other concrete subclasses of
1406+
# TypedValue
13781407
return Irreducible
13791408

13801409

1410+
def _can_nominal_types_intersect(t1: type[object], t2: type[object]) -> bool:
1411+
sb1 = _solid_base(t1)
1412+
sb2 = _solid_base(t2)
1413+
return safe_issubclass(sb1, sb2) or safe_issubclass(sb2, sb1)
1414+
1415+
1416+
SIZEOF_PYOBJECT = struct.calcsize("P")
1417+
1418+
1419+
def _shape_differs(t1: type[object], t2: type[object]) -> bool:
1420+
"""Check whether two types differ in shape.
1421+
1422+
Mirrors the shape_differs() function in typeobject.c in CPython."""
1423+
if sys.version_info >= (3, 12):
1424+
return (
1425+
t1.__basicsize__ != t2.__basicsize__ or t1.__itemsize__ != t2.__itemsize__
1426+
)
1427+
else:
1428+
# CPython had more complicated logic before 3.12:
1429+
# https://github.com/python/cpython/blob/f3c6f882cddc8dc30320d2e73edf019e201394fc/Objects/typeobject.c#L2224
1430+
# We attempt to mirror it here well enough to support the most common cases.
1431+
if t1.__itemsize__ or t2.__itemsize__:
1432+
return (
1433+
t1.__basicsize__ != t2.__basicsize__
1434+
or t1.__itemsize__ != t2.__itemsize__
1435+
)
1436+
t_size = t1.__basicsize__
1437+
if (
1438+
# TODO: better understanding of attributes of type objects
1439+
not t2.__weakrefoffset__ # static analysis: ignore[value_always_true]
1440+
and t1.__weakrefoffset__ + SIZEOF_PYOBJECT == t_size
1441+
):
1442+
t_size -= SIZEOF_PYOBJECT
1443+
if (
1444+
not t2.__dictoffset__ # static analysis: ignore[value_always_true]
1445+
and t1.__dictoffset__ + SIZEOF_PYOBJECT == t_size
1446+
):
1447+
t_size -= SIZEOF_PYOBJECT
1448+
if (
1449+
not t2.__weakrefoffset__ # static analysis: ignore[value_always_true]
1450+
and t2.__weakrefoffset__ == t_size
1451+
):
1452+
t_size -= SIZEOF_PYOBJECT
1453+
return t_size != t2.__basicsize__
1454+
1455+
1456+
def _solid_base(typ: type[object]) -> type[object]:
1457+
"""Return the "solid base" of a type, mirroring the logic in CPython's typeobject.c.
1458+
1459+
A "solid base" is a base that determines the shape of the type."""
1460+
if typ is object:
1461+
return object
1462+
base = typ.__base__
1463+
assert base is not None, f"Type {typ} has no base"
1464+
if _shape_differs(typ, base):
1465+
return typ
1466+
return _solid_base(base)
1467+
1468+
13811469
def _intersect_maybe_mutable(
13821470
left: Value,
13831471
left_readonly: bool,

pycroscope/test_relations.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,25 @@ def capybara(
4747

4848
assert_type(y, Intersection[A, B])
4949
assert_type(y.x, Intersection[int, Any])
50+
51+
@assert_passes()
52+
def test_typed_value_intersections(self):
53+
from typing_extensions import Never, assert_type, final
54+
55+
from pycroscope.extensions import Intersection
56+
57+
class A:
58+
x: int
59+
60+
@final
61+
class B:
62+
x: str
63+
64+
def capybara(
65+
ab: Intersection[A, B],
66+
int_str: Intersection[int, str],
67+
a_int: Intersection[A, int],
68+
) -> None:
69+
assert_type(ab, Never)
70+
assert_type(int_str, Never)
71+
assert_type(a_int, Never) # E: inference_failure

pycroscope/type_object.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
Signature,
1919
)
2020

21-
from .safe import safe_in, safe_isinstance, safe_issubclass
21+
from .safe import safe_getattr, safe_in, safe_isinstance, safe_issubclass
2222
from .value import (
2323
UNINITIALIZED_VALUE,
2424
AnySource,
@@ -53,6 +53,7 @@ def get_mro(typ: Union[type, super]) -> Sequence[type]:
5353
class TypeObject:
5454
typ: Union[type, super, str]
5555
base_classes: set[Union[type, str]] = field(default_factory=set)
56+
is_final: bool = False
5657
is_protocol: bool = False
5758
protocol_members: set[str] = field(default_factory=set)
5859
is_thrift_enum: bool = field(init=False)
@@ -73,6 +74,8 @@ def __post_init__(self) -> None:
7374
else:
7475
assert isinstance(self.typ, type), repr(self.typ)
7576
self.is_universally_assignable = issubclass(self.typ, mock.NonCallableMock)
77+
if safe_getattr(self.typ, "__final__", False):
78+
self.is_final = True
7679
self.is_thrift_enum = hasattr(self.typ, "_VALUES_TO_NAMES")
7780
self.base_classes |= set(get_mro(self.typ))
7881
# As a special case, the Python type system treats int as

pycroscope/typeshed.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,35 @@ def get_argspec_for_fully_qualified_name(
287287
)
288288
return sig
289289

290+
def is_final(self, fq_name: Union[str, type]) -> bool:
291+
"""Return whether this type is marked as final in the stubs."""
292+
if isinstance(fq_name, type):
293+
maybe_fq_name = self._get_fq_name(fq_name)
294+
if maybe_fq_name is None:
295+
return False
296+
fq_name = maybe_fq_name
297+
info = self._get_info_for_name(fq_name)
298+
mod, _ = fq_name.rsplit(".", maxsplit=1)
299+
return self._is_final_from_info(info, mod)
300+
301+
def _is_final_from_info(
302+
self, info: typeshed_client.resolver.ResolvedName, mod: str
303+
) -> bool:
304+
if info is None:
305+
return False
306+
if isinstance(info, typeshed_client.ImportedInfo):
307+
return self._is_final_from_info(info.info, mod)
308+
if isinstance(info, typeshed_client.NameInfo) and isinstance(
309+
info.ast, ast.ClassDef
310+
):
311+
for deco in info.ast.decorator_list:
312+
deco_value = self._parse_expr(deco, mod)
313+
if isinstance(deco_value, KnownValue) and is_typing_name(
314+
deco_value.val, "final"
315+
):
316+
return True
317+
return False
318+
290319
def get_bases(self, typ: type) -> Optional[list[Value]]:
291320
"""Return the base classes for this type, including generic bases."""
292321
return self.get_bases_for_value(TypedValue(typ))

0 commit comments

Comments
 (0)