Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
675bec5
gh-92810: Avoid O(n^2) complexity in ABCMeta.__subclasscheck__
dolfinus Mar 30, 2025
701ecc9
gh-92810: Apply fixes
dolfinus Mar 31, 2025
041f109
gh-92810: Apply fixes
dolfinus Mar 31, 2025
9bc4385
gh-92810: Apply fixes
dolfinus Mar 31, 2025
3d80b1e
gh-92810: Apply fixes
dolfinus Mar 31, 2025
b7603e0
gh-92810: Return __subclasses__clause back
dolfinus Apr 21, 2025
dd0d18c
gh-92810: Revert _abc.c changes
dolfinus Apr 21, 2025
8d695fd
gh-92810: Fix linter errors
dolfinus Apr 21, 2025
a2650b6
gh-92810: Add recursive issubclass check to _abc.c
dolfinus Jun 13, 2025
7afa5ea
gh-92810: Remove WeakKeyDictionary from _py_abc
dolfinus Jun 13, 2025
57980d3
gh-92810: Add news entry
dolfinus Jun 13, 2025
bbaf38a
gh-92810: Fix news entry
dolfinus Jun 13, 2025
6fc994d
gh-92810: Fixes after review
dolfinus Jun 22, 2025
b3b5895
gh-92810: Fixes after review
dolfinus Jun 22, 2025
69c5038
gh-92810: Fixes after review
dolfinus Jun 23, 2025
dc1b6d5
gh-92810: Fixes after review
dolfinus Jun 23, 2025
cd097ab
gh-92810: Introduce FT wrappers for uint64_t atomics
dolfinus Jun 23, 2025
f3a21a7
gh-92810: Use FT atomic wrappers for ABC invalidation counter
dolfinus Jun 23, 2025
e24e815
gh-92810: Fix missing FT wrapper
dolfinus Jun 23, 2025
b723912
gh-92810: Address review fixes
dolfinus Aug 4, 2025
0295846
Merge branch 'main' into improvement/ABCMeta_subclasscheck
dolfinus Aug 4, 2025
16f39bd
gh-92810: Address review fixes
dolfinus Aug 4, 2025
2dc6453
gh-92810: Add What's New entry
dolfinus Aug 4, 2025
968766d
gh-92810: Fix What's New entry syntax
dolfinus Aug 4, 2025
a6e4461
gh-92810: Address review fixes
dolfinus Aug 4, 2025
80d3281
gh-92810: Address review fixes
dolfinus Aug 4, 2025
ff38b9e
gh-92810: Properly reset recursion check
dolfinus Aug 4, 2025
23df287
Merge branch 'main' into improvement/ABCMeta_subclasscheck
dolfinus Aug 4, 2025
0bf7374
Merge branch 'main' into improvement/ABCMeta_subclasscheck
dolfinus Aug 7, 2025
d537859
Merge branch 'main' into improvement/ABCMeta_subclasscheck
dolfinus Sep 9, 2025
d17de08
Merge branch 'main' into improvement/ABCMeta_subclasscheck
dolfinus Nov 4, 2025
858d4c0
gh-92810: Fix What's New entry
dolfinus Nov 4, 2025
7b6a0dc
gh-92810: Improve nested subclass check performance
dolfinus Nov 4, 2025
2f8f2b2
gh-92810: Automatically add ABC registry entries to cache
dolfinus Nov 4, 2025
b657cd6
gh-92810: Remove false fastpath
dolfinus Nov 6, 2025
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
12 changes: 12 additions & 0 deletions Doc/whatsnew/3.15.rst
Original file line number Diff line number Diff line change
Expand Up @@ -679,6 +679,18 @@ xml.parsers.expat

.. _billion laughs: https://en.wikipedia.org/wiki/Billion_laughs_attack

abc
---

* Reduce memory usage of :func:`issubclass` checks for classes inheriting abstract classes.

:class:`abc.ABCMeta` hook ``__subclasscheck__`` now includes
a guard which is triggered then the hook is called from a parent class
(``issubclass(cls, RootClass)`` -> ``issubclass(cls, NestedClass)`` -> ...).
This guard prevents adding ``cls`` to ``NestedClass`` positive and negative caches,
preventing memory bloat in some cases (thousands of classes inherited from ABC).

(Contributed by Maxim Martynov in :gh:`92810`.)

zlib
----
Expand Down
9 changes: 9 additions & 0 deletions Include/internal/pycore_pyatomic_ft_wrappers.h
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ extern "C" {
_Py_atomic_load_uint16_relaxed(&value)
#define FT_ATOMIC_LOAD_UINT32_RELAXED(value) \
_Py_atomic_load_uint32_relaxed(&value)
#define FT_ATOMIC_LOAD_UINT64_RELAXED(value) \
_Py_atomic_load_uint64_relaxed(&value)
#define FT_ATOMIC_LOAD_ULONG_RELAXED(value) \
_Py_atomic_load_ulong_relaxed(&value)
#define FT_ATOMIC_STORE_PTR_RELAXED(value, new_value) \
Expand All @@ -61,6 +63,8 @@ extern "C" {
_Py_atomic_store_uint16_relaxed(&value, new_value)
#define FT_ATOMIC_STORE_UINT32_RELAXED(value, new_value) \
_Py_atomic_store_uint32_relaxed(&value, new_value)
#define FT_ATOMIC_STORE_UINT64_RELAXED(value, new_value) \
_Py_atomic_store_uint64_relaxed(&value, new_value)
#define FT_ATOMIC_STORE_CHAR_RELAXED(value, new_value) \
_Py_atomic_store_char_relaxed(&value, new_value)
#define FT_ATOMIC_LOAD_CHAR_RELAXED(value) \
Expand Down Expand Up @@ -111,6 +115,8 @@ extern "C" {
_Py_atomic_load_ullong_relaxed(&value)
#define FT_ATOMIC_ADD_SSIZE(value, new_value) \
(void)_Py_atomic_add_ssize(&value, new_value)
#define FT_ATOMIC_ADD_UINT64(value, new_value) \
(void)_Py_atomic_add_uint64(&value, new_value)
#define FT_MUTEX_LOCK(lock) PyMutex_Lock(lock)
#define FT_MUTEX_UNLOCK(lock) PyMutex_Unlock(lock)

Expand All @@ -128,6 +134,7 @@ extern "C" {
#define FT_ATOMIC_LOAD_UINT8_RELAXED(value) value
#define FT_ATOMIC_LOAD_UINT16_RELAXED(value) value
#define FT_ATOMIC_LOAD_UINT32_RELAXED(value) value
#define FT_ATOMIC_LOAD_UINT64_RELAXED(value) value
#define FT_ATOMIC_LOAD_ULONG_RELAXED(value) value
#define FT_ATOMIC_STORE_PTR_RELAXED(value, new_value) value = new_value
#define FT_ATOMIC_STORE_PTR_RELEASE(value, new_value) value = new_value
Expand All @@ -136,6 +143,7 @@ extern "C" {
#define FT_ATOMIC_STORE_UINT8_RELAXED(value, new_value) value = new_value
#define FT_ATOMIC_STORE_UINT16_RELAXED(value, new_value) value = new_value
#define FT_ATOMIC_STORE_UINT32_RELAXED(value, new_value) value = new_value
#define FT_ATOMIC_STORE_UINT64_RELAXED(value, new_value) value = new_value
#define FT_ATOMIC_LOAD_CHAR_RELAXED(value) value
#define FT_ATOMIC_STORE_CHAR_RELAXED(value, new_value) value = new_value
#define FT_ATOMIC_LOAD_UCHAR_RELAXED(value) value
Expand All @@ -161,6 +169,7 @@ extern "C" {
#define FT_ATOMIC_LOAD_ULLONG_RELAXED(value) value
#define FT_ATOMIC_STORE_ULLONG_RELAXED(value, new_value) value = new_value
#define FT_ATOMIC_ADD_SSIZE(value, new_value) (void)(value += new_value)
#define FT_ATOMIC_ADD_UINT64(value, new_value) (void)(value += new_value)
#define FT_MUTEX_LOCK(lock) do {} while (0)
#define FT_MUTEX_UNLOCK(lock) do {} while (0)

Expand Down
26 changes: 23 additions & 3 deletions Lib/_py_abc.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ def __new__(mcls, name, bases, namespace, /, **kwargs):
cls._abc_cache = WeakSet()
cls._abc_negative_cache = WeakSet()
cls._abc_negative_cache_version = ABCMeta._abc_invalidation_counter
cls._abc_issubclasscheck_recursive = False
return cls

def register(cls, subclass):
Expand All @@ -65,7 +66,10 @@ def register(cls, subclass):
if issubclass(cls, subclass):
# This would create a cycle, which is bad for the algorithm below
raise RuntimeError("Refusing to create an inheritance cycle")
# Add registry entry
cls._abc_registry.add(subclass)
# Automatically include cache entry
cls._abc_cache.add(subclass)
ABCMeta._abc_invalidation_counter += 1 # Invalidate negative cache
return subclass

Expand Down Expand Up @@ -139,9 +143,25 @@ def __subclasscheck__(cls, subclass):
return True
# Check if it's a subclass of a subclass (recursive)
for scls in cls.__subclasses__():
if issubclass(subclass, scls):
cls._abc_cache.add(subclass)
# If inside recursive issubclass check, avoid adding classes
# to any cache because this may drastically increase memory usage.
# Unfortunately, issubclass/__subclasscheck__ don't accept third
# argument with context, so using global context within ABCMeta.
# This is done only on first method call, next will use cache anyway.
scls_is_abc = hasattr(scls, "_abc_issubclasscheck_recursive")
if scls_is_abc:
scls._abc_issubclasscheck_recursive = True
try:
# Perform recursive check
result = issubclass(subclass, scls)
finally:
if scls_is_abc:
scls._abc_issubclasscheck_recursive = False
if result:
if not cls._abc_issubclasscheck_recursive:
cls._abc_cache.add(subclass)
return True
# No dice; update negative cache
cls._abc_negative_cache.add(subclass)
if not cls._abc_issubclasscheck_recursive:
cls._abc_negative_cache.add(subclass)
return False
204 changes: 173 additions & 31 deletions Lib/test/test_abc.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,25 @@ def foo(): return 4


class TestABC(unittest.TestCase):
def check_isinstance(self, obj, target_class):
self.assertIsInstance(obj, target_class)
self.assertIsInstance(obj, (target_class,))
self.assertIsInstance(obj, target_class | target_class)

def check_not_isinstance(self, obj, target_class):
self.assertNotIsInstance(obj, target_class)
self.assertNotIsInstance(obj, (target_class,))
self.assertNotIsInstance(obj, target_class | target_class)

def check_issubclass(self, klass, target_class):
self.assertIsSubclass(klass, target_class)
self.assertIsSubclass(klass, (target_class,))
self.assertIsSubclass(klass, target_class | target_class)

def check_not_issubclass(self, klass, target_class):
self.assertNotIsSubclass(klass, target_class)
self.assertNotIsSubclass(klass, (target_class,))
self.assertNotIsSubclass(klass, target_class | target_class)

def test_ABC_helper(self):
# create an ABC using the helper class and perform basic checks
Expand Down Expand Up @@ -270,29 +289,75 @@ def x(self):
class C(metaclass=meta):
pass

def test_isinstance_direct_inheritance(self):
class A(metaclass=abc_ABCMeta):
pass
class B(A):
pass
class C(A):
pass

a = A()
b = B()
c = C()
# trigger caching
for _ in range(2):
self.check_isinstance(a, A)
self.check_not_isinstance(a, B)
self.check_not_isinstance(a, C)

self.check_isinstance(b, B)
self.check_isinstance(b, A)
self.check_not_isinstance(b, C)

self.check_isinstance(c, C)
self.check_isinstance(c, A)
self.check_not_isinstance(c, B)

self.check_issubclass(B, A)
self.check_issubclass(C, A)
self.check_not_issubclass(B, C)
self.check_not_issubclass(C, B)
self.check_not_issubclass(A, B)
self.check_not_issubclass(A, C)

def test_registration_basics(self):
class A(metaclass=abc_ABCMeta):
pass
class B(object):
pass

a = A()
b = B()
self.assertNotIsSubclass(B, A)
self.assertNotIsSubclass(B, (A,))
self.assertNotIsInstance(b, A)
self.assertNotIsInstance(b, (A,))
# trigger caching
for _ in range(2):
self.check_not_issubclass(B, A)
self.check_not_isinstance(b, A)

self.check_not_issubclass(A, B)
self.check_not_isinstance(a, B)

B1 = A.register(B)
self.assertIsSubclass(B, A)
self.assertIsSubclass(B, (A,))
self.assertIsInstance(b, A)
self.assertIsInstance(b, (A,))
self.assertIs(B1, B)
# trigger caching
for _ in range(2):
self.check_issubclass(B, A)
self.check_isinstance(b, A)
self.assertIs(B1, B)

self.check_not_issubclass(A, B)
self.check_not_isinstance(a, B)

class C(B):
pass

c = C()
self.assertIsSubclass(C, A)
self.assertIsSubclass(C, (A,))
self.assertIsInstance(c, A)
self.assertIsInstance(c, (A,))
# trigger caching
for _ in range(2):
self.check_issubclass(C, A)
self.check_isinstance(c, A)

self.check_not_issubclass(A, C)
self.check_not_isinstance(a, C)

def test_register_as_class_deco(self):
class A(metaclass=abc_ABCMeta):
Expand Down Expand Up @@ -377,39 +442,73 @@ class A(metaclass=abc_ABCMeta):
pass
self.assertIsSubclass(A, A)
self.assertIsSubclass(A, (A,))

class B(metaclass=abc_ABCMeta):
pass
self.assertNotIsSubclass(A, B)
self.assertNotIsSubclass(A, (B,))
self.assertNotIsSubclass(B, A)
self.assertNotIsSubclass(B, (A,))

class C(metaclass=abc_ABCMeta):
pass
A.register(B)
class B1(B):
pass
self.assertIsSubclass(B1, A)
self.assertIsSubclass(B1, (A,))
# trigger caching
for _ in range(2):
self.assertIsSubclass(B1, A)
self.assertIsSubclass(B1, (A,))

class C1(C):
pass
B1.register(C1)
self.assertNotIsSubclass(C, B)
self.assertNotIsSubclass(C, (B,))
self.assertNotIsSubclass(C, B1)
self.assertNotIsSubclass(C, (B1,))
self.assertIsSubclass(C1, A)
self.assertIsSubclass(C1, (A,))
self.assertIsSubclass(C1, B)
self.assertIsSubclass(C1, (B,))
self.assertIsSubclass(C1, B1)
self.assertIsSubclass(C1, (B1,))
# trigger caching
for _ in range(2):
self.assertNotIsSubclass(C, B)
self.assertNotIsSubclass(C, (B,))
self.assertNotIsSubclass(C, B1)
self.assertNotIsSubclass(C, (B1,))
self.assertIsSubclass(C1, A)
self.assertIsSubclass(C1, (A,))
self.assertIsSubclass(C1, B)
self.assertIsSubclass(C1, (B,))
self.assertIsSubclass(C1, B1)
self.assertIsSubclass(C1, (B1,))

C1.register(int)
class MyInt(int):
pass
self.assertIsSubclass(MyInt, A)
self.assertIsSubclass(MyInt, (A,))
self.assertIsInstance(42, A)
self.assertIsInstance(42, (A,))
# trigger caching
for _ in range(2):
self.assertIsSubclass(MyInt, A)
self.assertIsSubclass(MyInt, (A,))
self.assertIsInstance(42, A)
self.assertIsInstance(42, (A,))

def test_custom_subclasses(self):
class A: pass
class B: pass

class Parent1(metaclass=abc_ABCMeta):
@classmethod
def __subclasses__(cls):
return [A]

class Parent2(metaclass=abc_ABCMeta):
__subclasses__ = lambda: [A]

# trigger caching
for _ in range(2):
self.check_isinstance(A(), Parent1)
self.check_issubclass(A, Parent1)
self.check_not_isinstance(B(), Parent1)
self.check_not_issubclass(B, Parent1)

self.check_isinstance(A(), Parent2)
self.check_issubclass(A, Parent2)
self.check_not_isinstance(B(), Parent2)
self.check_not_issubclass(B, Parent2)

def test_issubclass_bad_arguments(self):
class A(metaclass=abc_ABCMeta):
Expand Down Expand Up @@ -460,8 +559,32 @@ class S(metaclass=abc_ABCMeta):
with self.assertRaisesRegex(CustomError, exc_msg):
issubclass(int, S)

def test_subclasshook(self):
def test_issubclass_bad_class(self):
class A(metaclass=abc.ABCMeta):
pass

A._abc_impl = 1
error_msg = "_abc_impl is set to a wrong type"
with self.assertRaisesRegex(TypeError, error_msg):
issubclass(A, A)

class B(metaclass=_py_abc.ABCMeta):
pass

B._abc_cache = 1
error_msg = "argument of type 'int' is not a container or iterable"
with self.assertRaisesRegex(TypeError, error_msg):
issubclass(B, B)

class C(metaclass=_py_abc.ABCMeta):
pass

C._abc_negative_cache = 1
with self.assertRaisesRegex(TypeError, error_msg):
issubclass(C, C)

def test_subclasshook(self):
class A(metaclass=abc_ABCMeta):
@classmethod
def __subclasshook__(cls, C):
if cls is A:
Expand All @@ -478,6 +601,26 @@ class C:
self.assertNotIsSubclass(C, A)
self.assertNotIsSubclass(C, (A,))

def test_subclasshook_exception(self):
# Check that issubclass() propagates exceptions raised by
# __subclasshook__.
class CustomError(Exception): ...
exc_msg = "exception from __subclasshook__"
class A(metaclass=abc_ABCMeta):
@classmethod
def __subclasshook__(cls, C):
raise CustomError(exc_msg)
with self.assertRaisesRegex(CustomError, exc_msg):
issubclass(A, A)
class B(A):
pass
with self.assertRaisesRegex(CustomError, exc_msg):
issubclass(B, A)
class C:
pass
with self.assertRaisesRegex(CustomError, exc_msg):
issubclass(C, A)

def test_all_new_methods_are_called(self):
class A(metaclass=abc_ABCMeta):
pass
Expand Down Expand Up @@ -522,7 +665,6 @@ def foo(self):
self.assertEqual(A.__abstractmethods__, set())
A()


def test_update_new_abstractmethods(self):
class A(metaclass=abc_ABCMeta):
@abc.abstractmethod
Expand Down
Loading
Loading