commit:     d2bbe7925757e6d00baf4800034a44e01296ef2b
Author:     Brian Harring <ferringb <AT> gmail <DOT> com>
AuthorDate: Fri Nov 28 23:39:57 2025 +0000
Commit:     Brian Harring <ferringb <AT> gmail <DOT> com>
CommitDate: Fri Nov 28 23:44:06 2025 +0000
URL:        
https://gitweb.gentoo.org/proj/pkgcore/snakeoil.git/commit/?id=d2bbe792

feat: implement code_quality.Modules for __all__ validation

I'm unclear how it got broke, but the ExportedModules class
does *nothing*, so just turn it into a deprecated noop.  It
literally doesn't run in any usage of it.

This is a rewrite of it that's saner and parameterized and
defaults to failing on __all__'s referencing non existant
exports.

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

 src/snakeoil/test/code_quality.py | 87 ++++++++++++++++++++++++++-------------
 src/snakeoil/test/modules.py      | 16 +++----
 tests/test_code_quality.py        | 10 +++--
 tests/test_source_hygene.py       |  5 ---
 4 files changed, 71 insertions(+), 47 deletions(-)

diff --git a/src/snakeoil/test/code_quality.py 
b/src/snakeoil/test/code_quality.py
index 9fef747..d8d135d 100644
--- a/src/snakeoil/test/code_quality.py
+++ b/src/snakeoil/test/code_quality.py
@@ -1,7 +1,9 @@
+__all__ = ("ParameterizeBase", "Slots", "Modules")
 import abc
 import functools
-import types
+import inspect
 import typing
+from types import ModuleType
 
 import pytest
 
@@ -15,10 +17,10 @@ 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
+class ParameterizeBase(typing.Generic[T], abc.ABC):
+    namespaces: tuple[str]
+    namespace_ignores: tuple[str, ...] = ()
+    strict: tuple[str] | bool = False
     tests_to_parameterize: typing.ClassVar[tuple[str, ...]]
 
     # ABC doesn't apply to actual attributes, thus this.
@@ -30,11 +32,13 @@ class ParameterizeTest(abc.ABC, typing.Generic[T]):
 
     @classmethod
     @abc.abstractmethod
-    def collect_parameters(
-        cls, modules: set[types.ModuleType]
-    ) -> typing.Iterable[T]: ...
+    def collect_parameters(cls, modules: set[ModuleType]) -> 
typing.Iterable[T]: ...
 
     def __init_subclass__(cls) -> None:
+        super().__init_subclass__()
+
+        if inspect.isabstract(cls):
+            return
         if cls.is_abstract_still:
             del cls.is_abstract_still
             return
@@ -45,12 +49,6 @@ class ParameterizeTest(abc.ABC, typing.Generic[T]):
             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)
         )
@@ -59,29 +57,35 @@ class ParameterizeTest(abc.ABC, typing.Generic[T]):
             original = getattr(cls, test)
 
             @pytest.mark.parametrize(
-                "cls",
+                "param",
                 targets,
                 ids=cls.make_id,
             )
             @functools.wraps(original)
-            def do_it(self, cls: type, original=original):
-                return original(self, cls)
+            def do_it(self, param: T, original=original):
+                return original(self, param)
 
-            if test not in strict:
+            if not cls.is_strict_test(test):
                 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]:
+    def is_strict_test(cls, test_name: str) -> bool:
+        if isinstance(cls.strict, bool):
+            return cls.strict
+        return test_name in cls.strict
+
+    @classmethod
+    def collect_modules(cls) -> typing.Iterable[ModuleType]:
         for namespace in cls.namespaces:
             yield from get_submodules_of(
                 __import__(namespace), dont_import=cls.namespace_ignores
             )
 
 
-class Slots(ParameterizeTest[type]):
+class Slots(ParameterizeBase[type]):
     disable_str: typing.Final = "__slotting_intentionally_disabled__"
     ignored_subclasses: tuple[type, ...] = (Exception,)
 
@@ -113,20 +117,47 @@ class Slots(ParameterizeTest[type]):
         """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
+    def test_slots_mandatory(self, param: type):
+        assert get_slot_of(param).slots is not None or getattr(
+            param, 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:
+    def test_shadowing(self, param: type):
+        if (slots := get_slot_of(param).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:
+        for slotting in get_slots_of(param):
+            if slotting.cls is param:
                 continue
             if slotting.slots is not None:
                 assert set() == slots.intersection(slotting.slots), (
-                    f"has slots that shadow {cls}"
+                    f"has slots that shadow {param}"
                 )
+
+
+class Modules(ParameterizeBase[ModuleType]):
+    is_abstract_still = True
+    tests_to_parameterize = (
+        "test_has__all__",
+        "test_valid__all__",
+    )
+    strict = ("test_valid__all__",)
+
+    @classmethod
+    def make_id(cls, /, param: ModuleType) -> str:
+        return param.__name__
+
+    @classmethod
+    def collect_parameters(cls, modules) -> typing.Iterable[ModuleType]:
+        return modules
+
+    def test_has__all__(self, param: ModuleType):
+        assert hasattr(param, "__all__"), "__all__ is missing but should exist"
+
+    def test_valid__all__(self, param: ModuleType):
+        if attrs := getattr(param, "__all__", ()):
+            missing = {attr for attr in attrs if not hasattr(param, attr)}
+            assert not missing, (
+                f"__all__ refers to exports that don't exist: {missing!r}"
+            )

diff --git a/src/snakeoil/test/modules.py b/src/snakeoil/test/modules.py
index 9983300..4b5f249 100644
--- a/src/snakeoil/test/modules.py
+++ b/src/snakeoil/test/modules.py
@@ -1,13 +1,7 @@
-from snakeoil.test.mixins import PythonNamespaceWalker
+__all__ = ("ExportedModules",)
+from snakeoil.deprecation import deprecated
 
 
-class ExportedModules(PythonNamespaceWalker):
-    target_namespace = "snakeoil"
-
-    def test__all__accuracy(self):
-        failures = []
-        for module in self.walk_namespace(self.target_namespace):
-            for target in getattr(module, "__all__", ()):
-                if not hasattr(module, target):
-                    failures.append((module, target))
-        assert not failures, f"nonexistent __all__ targets spotted: {failures}"
+@deprecated("ExportedModules does nothing.  Use 
snakeoil.test.code_quality.Modules")
+class ExportedModules:
+    pass

diff --git a/tests/test_code_quality.py b/tests/test_code_quality.py
index c484cea..83b253d 100644
--- a/tests/test_code_quality.py
+++ b/tests/test_code_quality.py
@@ -1,8 +1,8 @@
-from snakeoil.test.code_quality import Slots
+from snakeoil.test import code_quality
 
 
-class TestSlots(Slots):
-    namespaces = ["snakeoil"]
+class TestSlots(code_quality.Slots):
+    namespaces = ("snakeoil",)
     namespace_ignores = (
         # The bulk of the ignores are since the code involved just needs to be 
rewritten
         "snakeoil.bash",
@@ -24,3 +24,7 @@ class TestSlots(Slots):
     )
     ignored_subclasses = (Exception,)
     strict = True
+
+
+class TestModules(code_quality.Modules):
+    namespaces = ("snakeoil",)

diff --git a/tests/test_source_hygene.py b/tests/test_source_hygene.py
deleted file mode 100644
index e454510..0000000
--- a/tests/test_source_hygene.py
+++ /dev/null
@@ -1,5 +0,0 @@
-from snakeoil.test.modules import ExportedModules
-
-
-class Test_modules(ExportedModules):
-    pass

Reply via email to