commit:     82d7926578631c1c52e411d00b5bb0c8132b3b10
Author:     Brian Harring <ferringb <AT> gmail <DOT> com>
AuthorDate: Fri Nov 28 19:41:19 2025 +0000
Commit:     Brian Harring <ferringb <AT> gmail <DOT> com>
CommitDate: Fri Nov 28 23:09:36 2025 +0000
URL:        
https://gitweb.gentoo.org/proj/pkgcore/snakeoil.git/commit/?id=82d79265

feat: rewrite SlotShadowing into test.code_quality.Slots

This is modernized to use parametrize, run individual checks,
and find things the previous was missing via just walking the
object class hierachy.

It also is far simpler to do suppressions.

Signed-off-by: Brian Harring <ferringb <AT> gmail.com>

 src/snakeoil/test/code_quality.py   | 132 ++++++++++++++++++++++++++++++++++++
 src/snakeoil/test/slot_shadowing.py |   2 +
 tests/test_code_quality.py          |  26 +++++++
 tests/test_slot_shadowing.py        |   5 --
 4 files changed, 160 insertions(+), 5 deletions(-)

diff --git a/src/snakeoil/test/code_quality.py 
b/src/snakeoil/test/code_quality.py
new file mode 100644
index 0000000..9fef747
--- /dev/null
+++ b/src/snakeoil/test/code_quality.py
@@ -0,0 +1,132 @@
+import abc
+import functools
+import types
+import typing
+
+import pytest
+
+from snakeoil.klass.util import (
+    get_slot_of,
+    get_slots_of,
+    get_subclasses_of,
+)
+from snakeoil.python_namespaces import get_submodules_of
+
+T = typing.TypeVar("T")
+
+
+class ParameterizeTest(abc.ABC, typing.Generic[T]):
+    namespaces: typing.List[str] | tuple[str]
+    namespace_ignores: typing.List[str] | tuple[str, ...] = ()
+    strict: typing.Container[str] | bool = False
+    tests_to_parameterize: typing.ClassVar[tuple[str, ...]]
+
+    # ABC doesn't apply to actual attributes, thus this.
+    is_abstract_still: typing.ClassVar[bool] = False
+
+    @classmethod
+    @abc.abstractmethod
+    def make_id(cls, /, param: T) -> str: ...
+
+    @classmethod
+    @abc.abstractmethod
+    def collect_parameters(
+        cls, modules: set[types.ModuleType]
+    ) -> typing.Iterable[T]: ...
+
+    def __init_subclass__(cls) -> None:
+        if cls.is_abstract_still:
+            del cls.is_abstract_still
+            return
+
+        if not hasattr(cls, "namespaces"):
+            raise TypeError("namespaces wasn't defined on the class")
+        if not hasattr(cls, "tests_to_parameterize"):
+            raise TypeError("tests_to_parameterize wasn't set")
+
+        # Inject the parameterization
+        strict = (
+            cls.strict
+            if not isinstance(cls.strict, bool)
+            else (cls.tests_to_parameterize if cls.strict else [])
+        )
+
+        targets = list(
+            sorted(cls.collect_parameters(set(cls.collect_modules())), 
key=cls.make_id)
+        )
+
+        for test in cls.tests_to_parameterize:
+            original = getattr(cls, test)
+
+            @pytest.mark.parametrize(
+                "cls",
+                targets,
+                ids=cls.make_id,
+            )
+            @functools.wraps(original)
+            def do_it(self, cls: type, original=original):
+                return original(self, cls)
+
+            if test not in strict:
+                do_it = pytest.mark.xfail(strict=False)(do_it)
+            setattr(cls, test, do_it)
+
+        super().__init_subclass__()
+
+    @classmethod
+    def collect_modules(cls) -> typing.Iterable[types.ModuleType]:
+        for namespace in cls.namespaces:
+            yield from get_submodules_of(
+                __import__(namespace), dont_import=cls.namespace_ignores
+            )
+
+
+class Slots(ParameterizeTest[type]):
+    disable_str: typing.Final = "__slotting_intentionally_disabled__"
+    ignored_subclasses: tuple[type, ...] = (Exception,)
+
+    is_abstract_still = True
+
+    tests_to_parameterize = (
+        "test_shadowing",
+        "test_slots_mandatory",
+    )
+
+    @classmethod
+    def make_id(cls, param: type) -> str:
+        return f"{param.__module__}.{param.__qualname__}"
+
+    @classmethod
+    def collect_parameters(cls, modules) -> typing.Iterable[type]:
+        modules = set(x.__name__ for x in modules)
+        for target in get_subclasses_of(object):
+            if not cls.ignore_module(target, modules) and not 
cls.ignore_class(target):
+                yield target
+
+    @classmethod
+    def ignore_module(cls, target: type, collected_modules: 
typing.Container[str]):
+        """Override if you need custom logic for the module filter of 
classes"""
+        return target.__module__ not in collected_modules
+
+    @classmethod
+    def ignore_class(cls, target: type) -> bool:
+        """Override this if you need dynamic suppression of which classes to 
ignore"""
+        return issubclass(target, cls.ignored_subclasses)
+
+    def test_slots_mandatory(self, cls: type):
+        assert get_slot_of(cls).slots is not None or getattr(
+            cls, self.disable_str, False
+        ), f"class has no slots nor is {self.disable_str} set to True"
+
+    def test_shadowing(self, cls: type):
+        if (slots := get_slot_of(cls).slots) is None:
+            return
+        assert isinstance(slots, tuple), "__slots__ must be a tuple"
+        slots = set(slots)
+        for slotting in get_slots_of(cls):
+            if slotting.cls is cls:
+                continue
+            if slotting.slots is not None:
+                assert set() == slots.intersection(slotting.slots), (
+                    f"has slots that shadow {cls}"
+                )

diff --git a/src/snakeoil/test/slot_shadowing.py 
b/src/snakeoil/test/slot_shadowing.py
index 71d120f..f003358 100644
--- a/src/snakeoil/test/slot_shadowing.py
+++ b/src/snakeoil/test/slot_shadowing.py
@@ -5,6 +5,7 @@ import warnings
 
 import pytest
 
+from snakeoil import deprecation
 from snakeoil.test.mixins import PythonNamespaceWalker
 
 
@@ -110,6 +111,7 @@ class KlassWalker(_classWalker):
                 yield node
 
 
[email protected]("use snakeoil.code_quality.Slots instead")
 class SlotShadowing(TargetedNamespaceWalker, SubclassWalker):
     target_namespace = "snakeoil"
     err_if_slots_is_str = True

diff --git a/tests/test_code_quality.py b/tests/test_code_quality.py
new file mode 100644
index 0000000..c484cea
--- /dev/null
+++ b/tests/test_code_quality.py
@@ -0,0 +1,26 @@
+from snakeoil.test.code_quality import Slots
+
+
+class TestSlots(Slots):
+    namespaces = ["snakeoil"]
+    namespace_ignores = (
+        # The bulk of the ignores are since the code involved just needs to be 
rewritten
+        "snakeoil.bash",
+        "snakeoil.chksum",
+        "snakeoil.cli.arghparse",
+        "snakeoil.compression",
+        "snakeoil.constraints",
+        "snakeoil.contexts",
+        "snakeoil.data_source",  # oofta on that class, py2k/py3k transition 
was brutal on that one.
+        "snakeoil.demandload",  # needs to be rewritten to descriptor protocol 
in particular.
+        "snakeoil.demandimport",  # may need rewrite, but isn't worth caring.  
Py3.15 renders this dead.
+        "snakeoil.klass.deprecated",
+        "snakeoil.dist",
+        "snakeoil.formatters",
+        "snakeoil.process",
+        "snakeoil.stringio",
+        "snakeoil.tar",
+        "snakeoil.test",
+    )
+    ignored_subclasses = (Exception,)
+    strict = True

diff --git a/tests/test_slot_shadowing.py b/tests/test_slot_shadowing.py
deleted file mode 100644
index 589d6ca..0000000
--- a/tests/test_slot_shadowing.py
+++ /dev/null
@@ -1,5 +0,0 @@
-from snakeoil.test.slot_shadowing import SlotShadowing
-
-
-class Test_slot_shadowing(SlotShadowing):
-    strict = True

Reply via email to