commit: d3438ebf23b639093dd0d432f9f9fe88040eefc5
Author: Brian Harring <ferringb <AT> gmail <DOT> com>
AuthorDate: Sat Dec 6 23:08:31 2025 +0000
Commit: Brian Harring <ferringb <AT> gmail <DOT> com>
CommitDate: Sat Dec 6 23:45:57 2025 +0000
URL:
https://gitweb.gentoo.org/proj/pkgcore/snakeoil.git/commit/?id=d3438ebf
chore: add abstractclassvar, simplify GenericEquality usage
abstractclass var is a trick to make abc.ABC enforce that derivative
classes must define a class var.
GenericEquality internal rules have been simplified- it no longer
allows disabling the logic since nothing used that. End users
can now pass `compare_slots=True` to get __attr_comparison__ auto
derived based on slot definition ordering.
Signed-off-by: Brian Harring <ferringb <AT> gmail.com>
src/snakeoil/klass/__init__.py | 100 ++++++++++++++++++++++++++------------
src/snakeoil/test/code_quality.py | 25 +++-------
tests/klass/test_init.py | 56 ++++++++++++++-------
3 files changed, 114 insertions(+), 67 deletions(-)
diff --git a/src/snakeoil/klass/__init__.py b/src/snakeoil/klass/__init__.py
index 55fc1ff..3824eee 100644
--- a/src/snakeoil/klass/__init__.py
+++ b/src/snakeoil/klass/__init__.py
@@ -7,6 +7,7 @@ involved in writing classes.
"""
__all__ = (
+ "abstractclassvar",
"combine_classes",
"generic_equality",
"reflective_hash",
@@ -37,10 +38,13 @@ __all__ = (
)
import abc
+import inspect
+import typing
from collections import deque
from operator import attrgetter
from snakeoil.deprecation import deprecated as warn_deprecated
+from snakeoil.sequences import unique_stable
from ..caching import WeakInstMeta
from .deprecated import (
@@ -73,6 +77,8 @@ from .util import (
sentinel = object()
+T = typing.TypeVar("T")
+
def GetAttrProxy(target):
def reflected_getattr(self, attr):
@@ -129,6 +135,38 @@ def reflective_hash(attr):
return __hash__
+class _abstractclassvar:
+ __slots__ = ()
+ __isabstractmethod__ = True
+
+
+def abstractclassvar(_: type[T]) -> T:
+ """
+ mechanism to use with ClassVars to force abc.ABC to block creation if the
subclass hasn't set it.
+
+ This can be used like thus:
+ >>> from typing import ClassVar
+ >>> class foon(abc.ABC):
+ ... required_class_var: ClassVar[str] = abstractclassvar(str)
+ ...
+ >>>
+ >>> foon()
+ Traceback (most recent call last):
+ File "<python-input-8>", line 1, in <module>
+ foon()
+ ~~~~^^
+ TypeError: Can't instantiate abstract class foon without an implementation
for abstract method 'required_class_var'
+
+ The error message implies a method when it's not, but that is a
limitation of abc.ABC. The point of this is to allow forcing that derivatives
create
+ the cvar, thus the trade off.
+
+ The mechanism currently is janky; you must pass in the type definition
since it's the
+ only way to attach this information to the returned object, lieing to the
type system
+ that the value is type compatible while carrying the marker abc.ABC needs.
+ """
+ return typing.cast(T, _abstractclassvar())
+
+
class GenericEquality(abc.ABC):
"""
implement simple __eq__/__ne__ comparison via a list of attributes to
compare
@@ -147,28 +185,18 @@ class GenericEquality(abc.ABC):
>>>
>>> assert kls() == foo()
>>> assert foo(3, 2, 1) != foo()
+
+ :cvar __attr_comparison__: tuple[str,...] that is the ordered sequence of
comparison to perform. For performance you should order this as the attributes
+ with the highest cardinality and cheap comparisons.
"""
__slots__ = ()
- # The pyright disable is since we're shoving in annotations in a weird way.
- # ABC is used to ensure this gets changed, but the actual class value in
the non virtual
- # class must be tuple.
- @property
- @abc.abstractmethod
- def __attr_comparison__(self) -> tuple[str, ...]: # pyright:
ignore[reportRedeclaration]
- """list of attributes to compare.
-
- This should be replaced with a tuple in derivative classes unless
dynamic
- behavior is needed for discerning what attributes to compare.
+ __attr_comparison__: typing.ClassVar[tuple[str, ...]] = abstractclassvar(
+ tuple[str, ...]
+ )
- The only reason to do this is if you're inheriting from something that
is GenericEquality,
- and you fully have overridden the comparison logic and wish to
document for any
- consumers __attr_comparison__ is no longer relevant.
- """
- pass
-
- __attr_comparison__: tuple[str, ...]
+ __attr_comparison__: typing.ClassVar[tuple[str, ...]]
def __eq__(
self, value, /, attr_comparison_override: tuple[str, ...] | None = None
@@ -189,21 +217,32 @@ class GenericEquality(abc.ABC):
return False
return True
- def __init_subclass__(cls) -> None:
- if cls.__attr_comparison__ is None:
- # __ne__ is just a reflection of __eq__, so just check that one.
- if cls.__eq__ is GenericEquality.__eq__:
+ def __init_subclass__(cls, compare_slots=False, **kwargs) -> None:
+ slotting = list(get_slots_of(cls))
+ if compare_slots:
+ if "__attr_comparison__" in cls.__dict__:
raise TypeError(
- "__attr_comparison__ was set to None, but __eq__ is still
GenericEquality.__eq__"
+ "compare_slots=True makes no sense when
__attr_comparison__ is explicitly set in the class directly"
)
- # If this is the first disabling, update the annotations
- if "__attr_comparison__" in cls.__dict__:
- cls.__annotations__["__attr_comparison__"] = None
+
+ all_slots = []
+ for slot in slotting:
+ if slot.slots is None:
+ raise TypeError(
+ f"compare_slots cannot be used: MRO chain has class
{slot.cls} which lacks slotting. Set __attr_comparison__ manually"
+ )
+ all_slots.extend(slot.slots)
+ cls.__attr_comparison__ = tuple(unique_stable(all_slots))
+ return super().__init_subclass__(**kwargs)
+
+ if inspect.isabstract(cls):
+ return super().__init_subclass__(**kwargs)
+
elif not isinstance(cls.__attr_comparison__, (tuple, property)):
raise TypeError(
f"__attr_comparison__ must be a tuple, received
{cls.__attr_comparison__!r}"
)
- return super().__init_subclass__()
+ return super().__init_subclass__(**kwargs)
class GenericRichComparison(GenericEquality):
@@ -272,10 +311,11 @@ def generic_equality(
metaclass generating __eq__/__ne__ methods from an attribute list
- The consuming class must set a class attribute named __attr_comparison__
- that is a sequence that lists the attributes to compare in determining
- equality or a string naming the class attribute to pull the list of
- attributes from (e.g. '__slots__').
+ The consuming class is abstract until a layer sets a class attribute
+ named `__attr_comparison__` which is the list of attributes to compare.
+
+ This can optionally derived via passing `compare_slots=True` in the class
+ creation.
:raise: TypeError if __attr_comparison__ is incorrectly defined
diff --git a/src/snakeoil/test/code_quality.py
b/src/snakeoil/test/code_quality.py
index 3899e09..d969ac4 100644
--- a/src/snakeoil/test/code_quality.py
+++ b/src/snakeoil/test/code_quality.py
@@ -7,7 +7,8 @@ from types import ModuleType
import pytest
-from snakeoil.klass.util import (
+from snakeoil.klass import (
+ abstractclassvar,
get_slot_of,
get_slots_of,
get_subclasses_of,
@@ -17,27 +18,13 @@ from snakeoil.python_namespaces import get_submodules_of
T = typing.TypeVar("T")
-class _abstractvar:
- __slots__ = ()
- __isabstractmethod__ = True
-
-
-def abstractvar(_: type[T]) -> T:
- """
- mechanism to use with ClassVars to force abc.ABC to block creation if the
subclass hasn't set it.
-
- The mechanism currently is janky; you must pass in the type definition
since it's the
- only way to attach this information to the returned object, lieing to the
type system
- that the value is type compatible while carrying the marker abc.ABC needs.
- """
- return typing.cast(T, _abstractvar())
-
-
class ParameterizeBase(typing.Generic[T], abc.ABC):
- namespaces: typing.ClassVar[tuple[str]] = abstractvar(tuple[str])
+ namespaces: typing.ClassVar[tuple[str]] = abstractclassvar(tuple[str])
namespace_ignores: tuple[str, ...] = ()
strict: tuple[str] | bool = False
- tests_to_parameterize: typing.ClassVar[tuple[str, ...]] =
abstractvar(tuple[str])
+ tests_to_parameterize: typing.ClassVar[tuple[str, ...]] = abstractclassvar(
+ tuple[str]
+ )
@classmethod
@abc.abstractmethod
diff --git a/tests/klass/test_init.py b/tests/klass/test_init.py
index 0e6ef13..7ccfae8 100644
--- a/tests/klass/test_init.py
+++ b/tests/klass/test_init.py
@@ -1,3 +1,5 @@
+import abc
+import inspect
import re
import sys
from functools import partial
@@ -612,29 +614,26 @@ class TestGenericEquality:
del obj2.x
assert obj1 == obj2, ".x is missing on both, they should be equal"
- # validate disabling logic.
- try:
+ def test_compare_slots(self):
+ class kls1(klass.GenericEquality, compare_slots=True):
+ __slots__ = ("a",)
- class broken(kls):
- __attr_comparison__ = None
+ with pytest.raises(TypeError):
- pytest.fail(
- "__attr_comparison__ was disabled, but __eq__ method is still
GenericEquality.__eq__"
- )
- except TypeError:
- pass
+ class kls2(kls1, compare_slots=True): ...
+
+ class kls3(kls1, compare_slots=True):
+ __slots__ = ()
- class subclass_disabling_must_be_allowed(kls):
- __attr_comparison__ = None
+ assert ("a",) == kls3.__attr_comparison__
- # we've overridden __eq__. The subclass check must allow this.
- def __eq__(self, other):
- return False
+ class kls4(kls3):
+ __slots__ = ("c",)
- assert (
-
subclass_disabling_must_be_allowed.__annotations__["__attr_comparison__"]
- is None
- ), "annotations weren't updated"
+ class kls5(kls4, compare_slots=True):
+ __slots__ = "b"
+
+ assert ("b", "c", "a") == kls5.__attr_comparison__
class TestGenericRichComparison:
@@ -675,3 +674,24 @@ class TestGenericRichComparison:
assert obj1 <= obj2
assert not (obj1 > obj2)
assert not (obj1 >= obj2)
+
+
+def test_abstractclassvar():
+ class kls1(abc.ABC): ...
+
+ assert not inspect.isabstract(kls1) # nothing abstract in it, despite the
base
+
+ class kls2(kls1):
+ blah = klass.abstractclassvar(str)
+
+ assert inspect.isabstract(kls2) # no abstract bits on it
+
+ class kls3(kls2): # validate extending to subclasses
+ ...
+
+ assert inspect.isabstract(kls3)
+
+ class kls4(kls3):
+ blah = "asdf"
+
+ assert not inspect.isabstract(kls4)