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)