commit:     242db09a335927ff449274b9279644c9dff0303d
Author:     Brian Harring <ferringb <AT> gmail <DOT> com>
AuthorDate: Sun Dec  7 18:51:21 2025 +0000
Commit:     Brian Harring <ferringb <AT> gmail <DOT> com>
CommitDate: Sun Dec  7 18:55:23 2025 +0000
URL:        
https://gitweb.gentoo.org/proj/pkgcore/snakeoil.git/commit/?id=242db09a

feat: add test.AbstractTest since pytest silently ignores ABC test classes.

Pytest intentionally ignores all classes that are still abstract during
collection.  This is entirely valid when one is dealing in intermediate
base classes, but there is no mechanism to say "I actually did want
it to be concrete".  If you make any mistake in the chain of subclassing
where you didn't override an abstract for a templated test you're adding,
pytest drops it.

This is an abc.ABC base for tests that requires explicitly marking
subclasses as abstract if that's the intention.  If they're not marked
but are still abstract, it throws a TypeError so people writing tests
see it, and resolve it.

Without this you can have tests silently dropping out of your suite.

It's a pain in the ass, even if I understand why they did what they did.

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

 src/snakeoil/test/__init__.py     |  3 +++
 src/snakeoil/test/abstract.py     | 29 +++++++++++++++++++++++++++++
 src/snakeoil/test/code_quality.py |  8 ++++----
 tests/test_test.py                | 23 +++++++++++++++++++++++
 4 files changed, 59 insertions(+), 4 deletions(-)

diff --git a/src/snakeoil/test/__init__.py b/src/snakeoil/test/__init__.py
index 9b8ef5b..1539552 100644
--- a/src/snakeoil/test/__init__.py
+++ b/src/snakeoil/test/__init__.py
@@ -1,6 +1,7 @@
 """Our unittest extensions."""
 
 __all__ = (
+    "AbstractTest",
     "coverage",
     "hide_imports",
     "Modules",
@@ -10,6 +11,7 @@ __all__ = (
     "Slots",
 )
 
+
 import os
 import random
 import string
@@ -17,6 +19,7 @@ import subprocess
 import sys
 from unittest.mock import patch
 
+from .abstract import AbstractTest
 from .code_quality import Modules, NamespaceCollector, Slots
 
 

diff --git a/src/snakeoil/test/abstract.py b/src/snakeoil/test/abstract.py
new file mode 100644
index 0000000..9efe9ce
--- /dev/null
+++ b/src/snakeoil/test/abstract.py
@@ -0,0 +1,29 @@
+__all__ = ("AbstractTest",)
+import abc
+import inspect
+
+
+class AbstractTest(abc.ABC):
+    """
+    Use this class for any abc.ABC test chains you create
+
+    Pytest silently ignores all test classes that are abstract.  This is 
good... until
+    you're filling out a test that you want concrete but either forgot to 
supply the missing
+    thing, or upstream added another abstact.
+
+    In either of those scenarios pytest silently drops the class during 
collection.
+
+    For subclasses that are *still* intentionally abstract that you do not 
want pytest
+    to collect, pass `still_abstract=True` in the class inheritance.
+
+    For the very first level of an inheritance of this class, it assumes 
`still_abstract=True`.  All
+    derivatives beyond that must be concrete or have that passed.
+    """
+
+    def __init_subclass__(cls, still_abstract=False, **kwargs):
+        if inspect.isabstract(cls):
+            if not still_abstract and AbstractTest not in cls.__bases__:
+                raise TypeError(
+                    "Test class inherits an abc.ABC that is still abstract; 
pytest will not collect this.  If you intended it to still be abstract, add 
`still_abstract=True` to the inherit"
+                )
+        return super().__init_subclass__(**kwargs)

diff --git a/src/snakeoil/test/code_quality.py 
b/src/snakeoil/test/code_quality.py
index a309afe..cac6dbb 100644
--- a/src/snakeoil/test/code_quality.py
+++ b/src/snakeoil/test/code_quality.py
@@ -1,5 +1,4 @@
 __all__ = ("NamespaceCollector", "Slots", "Modules")
-import abc
 import inspect
 import typing
 from types import ModuleType
@@ -13,6 +12,7 @@ from snakeoil.klass import (
     get_subclasses_of,
 )
 from snakeoil.python_namespaces import get_submodules_of
+from snakeoil.test import AbstractTest
 
 T = typing.TypeVar("T")
 
@@ -23,7 +23,7 @@ class maybe_strict_tests(list):
         return thing
 
 
-class NamespaceCollector(typing.Generic[T], abc.ABC):
+class NamespaceCollector(typing.Generic[T], AbstractTest):
     namespaces: tuple[str] = abstractclassvar(tuple[str])
     namespace_ignores: tuple[str, ...] = ()
 
@@ -50,7 +50,7 @@ class NamespaceCollector(typing.Generic[T], abc.ABC):
             )
 
 
-class Slots(NamespaceCollector[type]):
+class Slots(NamespaceCollector[type], still_abstract=True):
     disable_str: typing.Final = "__slotting_intentionally_disabled__"
     ignored_subclasses: tuple[type, ...] = (
         Exception,
@@ -97,7 +97,7 @@ class Slots(NamespaceCollector[type]):
                         )
 
 
-class Modules(NamespaceCollector[ModuleType]):
+class Modules(NamespaceCollector[ModuleType], still_abstract=True):
     strict_configurable_tests = (
         "test_has__all__",
         "test_valid__all__",

diff --git a/tests/test_test.py b/tests/test_test.py
index 5fc2a3b..eebd5cd 100644
--- a/tests/test_test.py
+++ b/tests/test_test.py
@@ -1,3 +1,5 @@
+import abc
+import inspect
 import os
 
 import pytest
@@ -48,3 +50,24 @@ class Test_protect_process:
             fail(pytestconfig=pytestconfig)  # pyright: ignore[reportCallIssue]
 
         assert unique_string in str(failed.value)
+
+
+def test_AbstractTest():
+    class base(test.AbstractTest):
+        @abc.abstractmethod
+        def f(self): ...
+
+    assert inspect.isabstract(base)
+
+    with pytest.raises(TypeError):
+
+        class must_be_explicitly_marked_abstract(base): ...
+
+    class still_abstract(base, still_abstract=True): ...
+
+    assert inspect.isabstract(still_abstract)
+
+    class not_abstract(still_abstract):
+        def f(self): ...
+
+    assert not inspect.isabstract(not_abstract)

Reply via email to