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)

Reply via email to