commit:     40026f9e99ea3b2d5c2bdc031d77b55a546d90b9
Author:     Brian Harring <ferringb <AT> gmail <DOT> com>
AuthorDate: Sun Dec  7 19:10:52 2025 +0000
Commit:     Brian Harring <ferringb <AT> gmail <DOT> com>
CommitDate: Sun Dec  7 20:37:38 2025 +0000
URL:        
https://gitweb.gentoo.org/proj/pkgcore/snakeoil.git/commit/?id=40026f9e

feat: add collector for expired deprecations

Also add the ability to drop a note into the code for
things that must be removed.  A general code directive..

For example, deprecation.Registry itself has code that
must be updated in python >=3.13.  Thus add a directive
for the tests to do so.

I'd prefer this to be not runtime- to push it into some
trick of the type system- but typing.Annotated must be
bound to something and that will lead to muddying up the
scopes of where the directive is placed.  Beyond that,
some of these directives *will be* runtime chained, and
this is intentional to allow a 'break glass' for things
I can't provide a better API for.

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

 src/snakeoil/__init__.py          |   4 ++
 src/snakeoil/_internals.py        |   5 ++
 src/snakeoil/deprecation.py       | 121 ++++++++++++++++++++++++++++++--------
 src/snakeoil/test/code_quality.py |  27 ++++++++-
 tests/test_code_quality.py        |   9 +++
 tests/test_deprecation.py         | 103 ++++++++++++++++++++++++++------
 6 files changed, 223 insertions(+), 46 deletions(-)

diff --git a/src/snakeoil/__init__.py b/src/snakeoil/__init__.py
index e78ca15..8665f0f 100644
--- a/src/snakeoil/__init__.py
+++ b/src/snakeoil/__init__.py
@@ -11,4 +11,8 @@ This library is a bit of a grabbag of the following:
 """
 
 __title__ = "snakeoil"
+# TODO: wire these all against py_build rendered values, or against git.  
Don't hardcode it here.
+# Once that's done, extend it to the rest of the ecosystem.
 __version__ = "0.11.0"
+__version_info__ = (0, 11, 0)
+__python_mininum_version__ = (3, 11, 0)

diff --git a/src/snakeoil/_internals.py b/src/snakeoil/_internals.py
index ff317ae..1641120 100644
--- a/src/snakeoil/_internals.py
+++ b/src/snakeoil/_internals.py
@@ -2,3 +2,8 @@ __all__ = ("deprecated",)
 from snakeoil.deprecation import Registry
 
 deprecated = Registry("snakeoil")
+# See Registry implementation
+deprecated.code_directive(
+    "snakeoil.deprecation.Registry.is_enabled will always have 
warnings.deprecated, this must be updated.",
+    removal_in_py=(3, 13, 0),
+)

diff --git a/src/snakeoil/deprecation.py b/src/snakeoil/deprecation.py
index fa2d7c6..9139df5 100644
--- a/src/snakeoil/deprecation.py
+++ b/src/snakeoil/deprecation.py
@@ -9,7 +9,7 @@ that can now be removed.
 
 """
 
-__all__ = ("Registry", "Record", "suppress_deprecations")
+__all__ = ("Registry", "RecordCallable", "suppress_deprecations")
 
 
 import contextlib
@@ -21,17 +21,42 @@ import warnings
 T = typing.TypeVar("T")
 P = typing.ParamSpec("P")
 
-Version: typing.TypeAlias = tuple[int, ...] | None
+Version: typing.TypeAlias = tuple[int, int, int]
 warning_category: typing.TypeAlias = type[Warning]
 
 
 @dataclasses.dataclass(slots=True, frozen=True)
 class Record:
-    thing: typing.Callable
     msg: str
-    removal_in: Version = None
-    removal_in_py: Version = None
-    category: warning_category = DeprecationWarning
+    removal_in: Version | None = None
+    removal_in_py: Version | None = None
+
+    def _collect_strings(self) -> typing.Iterator[str]:
+        if self.removal_in:
+            yield "removal in version=" + (".".join(map(str, self.removal_in)))
+        if self.removal_in_py:
+            yield "removal in python=" + (".".join(map(str, 
self.removal_in_py)))
+        yield f"reason: {self.msg}"
+
+    def __str__(self) -> str:
+        return ", ".join(self._collect_strings())
+
+
[email protected](slots=True, frozen=True, kw_only=True)
+class RecordCallable(Record):
+    qualname: str
+
+    @classmethod
+    def from_callable(cls, thing: typing.Callable, *args, **kwargs) -> 
"RecordCallable":
+        if "locals()" in thing.__qualname__.split("."):
+            raise ValueError(
+                f"functor {thing!r} has .locals() in it; you need to provide 
the actual qualname"
+            )
+        return cls(*args, qualname=thing.__qualname__, **kwargs)
+
+    def _collect_strings(self) -> typing.Iterator[str]:
+        yield f"qualname={self.qualname!r}"
+        yield from super(RecordCallable, self)._collect_strings()
 
 
 # When py3.13 is the min, add a defaulted generic of Record in this, and
@@ -53,8 +78,10 @@ class Registry:
 
     __slots__ = ("project", "_deprecations", "record_class")
 
-    record_class: type[Record]
+    record_class: type[RecordCallable]
 
+    # Note: snakeoil._internals.deprecated adds the reminder for changing the 
logic
+    # of the Registry once >=3.13.0
     is_enabled: typing.ClassVar[bool] = sys.version_info >= (3, 13, 0)
     _deprecated_callable: typing.Callable | None
 
@@ -66,11 +93,13 @@ class Registry:
     if is_enabled:
         from warnings import deprecated as _deprecated_callable
 
-    def __init__(self, project: str, /, *, record_class: type[Record] = 
Record):
+    def __init__(
+        self, project: str, /, *, record_class: type[RecordCallable] = 
RecordCallable
+    ):
         self.project = project
         # TODO: py3.13, change this to T per the cvar comments
         self.record_class = record_class
-        self._deprecations: list[Record] = []
+        self._deprecations: list[Record | RecordCallable] = []
         super().__init__()
 
     def __call__(
@@ -78,37 +107,58 @@ class Registry:
         msg: str,
         /,
         *,
-        removal_in: Version = None,
-        removal_in_py: Version = None,
-        category: warning_category = DeprecationWarning,
+        removal_in: Version | None = None,
+        removal_in_py: Version | None = None,
+        qualname: str | None = None,
+        category=DeprecationWarning,
+        stacklevel=1,
         **kwargs,
     ):
         """Decorate a callable with a deprecation notice, registering it in 
the internal list of deprecations"""
 
-        def f(thing):
+        def f(functor):
             if not self.is_enabled:
-                return thing
+                return functor
 
             result = typing.cast(typing.Callable, self._deprecated_callable)(
-                msg,
-                category=category,
-                stacklevel=kwargs.pop("stacklevel", 1),
-            )(thing)
-
-            self._deprecations.append(
-                self.record_class(
-                    thing,
+                msg, category=category, stacklevel=stacklevel
+            )(functor)
+
+            # unify the below.  That .from_callable is working dataclasses 
annoying __init__ restrictions.
+            if qualname is not None:
+                r = self.record_class(
                     msg,
-                    category=category,
                     removal_in=removal_in,
                     removal_in_py=removal_in_py,
+                    qualname=qualname,
                     **kwargs,
                 )
-            )
+            else:
+                r = self.record_class.from_callable(
+                    functor,
+                    msg,
+                    removal_in=removal_in,
+                    removal_in_py=removal_in_py,
+                    **kwargs,
+                )
+            self._deprecations.append(r)
             return result
 
         return f
 
+    def code_directive(
+        self,
+        msg: str,
+        removal_in: Version | None = None,
+        removal_in_py: Version | None = None,
+    ) -> None:
+        if not removal_in and not removal_in_py:
+            raise ValueError("either removal_in or removal_in_py must be set")
+        """Add a directive in the code that if invoked, records the 
deprecation"""
+        self._deprecations.append(
+            Record(msg=msg, removal_in=removal_in, removal_in_py=removal_in_py)
+        )
+
     @staticmethod
     @contextlib.contextmanager
     def suppress_deprecations(
@@ -123,5 +173,28 @@ class Registry:
     def __iter__(self) -> typing.Iterator[Record]:
         return iter(self._deprecations)
 
+    def __nonzero__(self) -> bool:
+        return bool(self._deprecations)
+
+    def __len__(self) -> int:
+        return len(self._deprecations)
+
+    def expired_deprecations(
+        self,
+        project_version: Version,
+        python_version: Version,
+    ) -> typing.Iterator[Record]:
+        for deprecation in self:
+            if (
+                deprecation.removal_in is not None
+                and project_version >= deprecation.removal_in
+            ):
+                yield deprecation
+            elif (
+                deprecation.removal_in_py is not None
+                and python_version >= deprecation.removal_in_py
+            ):
+                yield deprecation
+
 
 suppress_deprecations = Registry.suppress_deprecations

diff --git a/src/snakeoil/test/code_quality.py 
b/src/snakeoil/test/code_quality.py
index cac6dbb..366b0f4 100644
--- a/src/snakeoil/test/code_quality.py
+++ b/src/snakeoil/test/code_quality.py
@@ -1,10 +1,12 @@
 __all__ = ("NamespaceCollector", "Slots", "Modules")
 import inspect
+import sys
 import typing
 from types import ModuleType
 
 import pytest
 
+from snakeoil import deprecation
 from snakeoil.klass import (
     abstractclassvar,
     get_slot_of,
@@ -23,7 +25,7 @@ class maybe_strict_tests(list):
         return thing
 
 
-class NamespaceCollector(typing.Generic[T], AbstractTest):
+class NamespaceCollector(AbstractTest):
     namespaces: tuple[str] = abstractclassvar(tuple[str])
     namespace_ignores: tuple[str, ...] = ()
 
@@ -50,7 +52,7 @@ class NamespaceCollector(typing.Generic[T], AbstractTest):
             )
 
 
-class Slots(NamespaceCollector[type], still_abstract=True):
+class Slots(NamespaceCollector, still_abstract=True):
     disable_str: typing.Final = "__slotting_intentionally_disabled__"
     ignored_subclasses: tuple[type, ...] = (
         Exception,
@@ -97,7 +99,7 @@ class Slots(NamespaceCollector[type], still_abstract=True):
                         )
 
 
-class Modules(NamespaceCollector[ModuleType], still_abstract=True):
+class Modules(NamespaceCollector, still_abstract=True):
     strict_configurable_tests = (
         "test_has__all__",
         "test_valid__all__",
@@ -117,3 +119,22 @@ class Modules(NamespaceCollector[ModuleType], 
still_abstract=True):
                     assert not missing, (
                         f"__all__ refers to exports that don't exist: 
{missing!r}"
                     )
+
+
+class ExpiredDeprecations(NamespaceCollector, still_abstract=True):
+    strict_configurable_tests = ("test_has_expired_deprecations",)
+    strict = ("test_has_expired_deprecations",)
+
+    registry: deprecation.Registry = abstractclassvar(deprecation.Registry)
+    version: deprecation.Version = abstractclassvar(deprecation.Version)
+    python_minimum_version: deprecation.Version = 
abstractclassvar(deprecation.Version)
+
+    def test_has_expired_deprecations(self, subtests):
+        # force full namespace load to ensure all deprecations get registry.
+        for _ in self.collect_modules():
+            pass
+        for deprecated in self.registry.expired_deprecations(
+            self.version, self.python_minimum_version
+        ):
+            with subtests.test(deprecated=str(deprecated)):
+                pytest.fail(f"deprecation has expired: {deprecated}")

diff --git a/tests/test_code_quality.py b/tests/test_code_quality.py
index 77c3f09..31a13d4 100644
--- a/tests/test_code_quality.py
+++ b/tests/test_code_quality.py
@@ -1,3 +1,5 @@
+import snakeoil
+import snakeoil._internals
 from snakeoil.test import code_quality
 
 
@@ -34,3 +36,10 @@ class TestModules(code_quality.Modules):
         "snakeoil.test.mixins",
         "snakeoil.test.slot_shadowing",
     )
+
+
+class TestExpiredDeprecations(code_quality.ExpiredDeprecations):
+    namespaces = ("snakeoil",)
+    registry = snakeoil._internals.deprecated
+    version = snakeoil.__version_info__
+    python_minimum_version = snakeoil.__python_mininum_version__

diff --git a/tests/test_deprecation.py b/tests/test_deprecation.py
index 6a94697..9bc6280 100644
--- a/tests/test_deprecation.py
+++ b/tests/test_deprecation.py
@@ -4,7 +4,7 @@ import warnings
 
 import pytest
 
-from snakeoil.deprecation import Record, Registry, suppress_deprecations
+from snakeoil.deprecation import Record, RecordCallable, Registry, 
suppress_deprecations
 
 requires_enabled = pytest.mark.skipif(
     not Registry.is_enabled, reason="requires python >=3.13.0"
@@ -20,14 +20,17 @@ class TestRegistry:
         r = Registry("tests")
         assert "tests" == r.project
         assert [] == list(r)
+        assert not r
 
         def f(x: int) -> int:
             return x + 1
 
-        f2 = r("test1")(f)
+        f2 = r("test1", qualname="asdf")(f)
         assert f2 is not f
         assert 1 == len(list(r))
-        assert Record(f, "test1", None, None, DeprecationWarning) == list(r)[0]
+        assert 1 == len(r)
+        assert r
+        assert RecordCallable("test1", qualname="asdf") == list(r)[0]
 
         with r.suppress_deprecations():
             assert 2 == f2(1)
@@ -35,17 +38,23 @@ class TestRegistry:
             with pytest.deprecated_call():
                 assert 2 == f2(1)
 
-        r("test2", removal_in=(5, 3, 0))(f)
-        assert 2 == len(list(r))
-        assert Record(f, "test2", (5, 3, 0), None, DeprecationWarning) == 
list(r)[-1]
+        r("test2", removal_in=(5, 3, 0), qualname="blah")(f)
+        assert 2 == len(r)
+        assert (
+            RecordCallable("test2", qualname="blah", removal_in=(5, 3, 0))
+            == list(r)[-1]
+        )
 
-        r("test3", removal_in_py=(4, 0))(f)
-        assert (Record(f, "test3", None, (4, 0), DeprecationWarning)) == 
list(r)[-1]
+        r("test3", removal_in_py=(4, 0, 0), qualname="test3")(f)
+        assert (
+            RecordCallable("test3", qualname="test3", removal_in_py=(4, 0, 0))
+        ) == list(r)[-1]
 
         class MyDeprecation(DeprecationWarning): ...
 
-        r("test4", category=MyDeprecation)(f)
-        assert (Record(f, "test4", None, None, MyDeprecation)) == list(r)[-1]
+        # just confirm it accepts it.  Post py3.13 add a mock here, should we 
be truly anal.
+        r("test4", category=MyDeprecation, qualname="test4")(f)
+        assert (RecordCallable("test4", qualname="test4")) == list(r)[-1]
 
     @pytest.mark.skipif(
         Registry.is_enabled, reason="test is only for python 3.12 and lower"
@@ -56,7 +65,7 @@ class TestRegistry:
         def f(): ...
 
         assert f is r("asdf")(f)
-        assert [] == list(r)
+        assert not r, f"r should be empty; contents were {r._deprecations}"
 
     def test_suppress_deprecations(self):
         # assert the convienence function and that we're just reusing the 
existing.
@@ -78,10 +87,10 @@ class TestRegistry:
     @requires_enabled
     def test_subclassing(self):
         # just assert record class can be extended- so downstream can add more 
metadata.
-        assert Record is Registry("asdf").record_class
+        assert RecordCallable is Registry("asdf").record_class
 
         @dataclasses.dataclass(slots=True, frozen=True, kw_only=True)
-        class MyRecord(Record):
+        class MyRecord(RecordCallable):
             extra_val1: int = 1
             extra_val2: int = 2
 
@@ -89,17 +98,73 @@ class TestRegistry:
 
         r = Registry("test", record_class=MyRecord)
 
-        r("asdf", extra_val1=3, extra_val2=4)(f)
-        assert 1 == len(list(r))
+        r("asdf", extra_val1=3, extra_val2=4, qualname="myrecord")(f)
+        assert 1 == len(r)
         assert (
             MyRecord(
-                f,
                 "asdf",
-                None,
-                None,
-                DeprecationWarning,
+                qualname="myrecord",
                 extra_val1=3,
                 extra_val2=4,
             )
             == list(r)[0]
         )
+
+    def test_expired_deprecations(self):
+        r = Registry("asdf")
+
+        def f(): ...
+
+        r("python", removal_in_py=(1, 0, 0))(f)
+        r("project", removal_in=(1, 0, 0))(f)
+        r(
+            "combined",
+            removal_in_py=(
+                2,
+                0,
+                0,
+            ),
+            removal_in=(2, 0, 0),
+        )(f)
+
+        assert 3 == len(r)
+        assert [] == list(r.expired_deprecations((0, 0, 0), (0, 0, 0)))
+        assert ["python"] == [
+            x.msg for x in r.expired_deprecations((0, 0, 0), 
python_version=(1, 0, 0))
+        ]
+        assert ["project"] == [
+            x.msg for x in r.expired_deprecations((1, 0, 0), 
python_version=(0, 0, 0))
+        ]
+        assert ["combined", "project", "python"] == list(
+            sorted(
+                x.msg
+                for x in r.expired_deprecations((2, 0, 0), python_version=(2, 
0, 0))
+            )
+        )
+
+    def test_code_directive(self):
+        r = Registry("test")
+        assert None is r.code_directive(
+            "asdf", removal_in=(1, 0, 0), removal_in_py=(2, 0, 0)
+        )
+        assert 1 == len(r)
+        assert (
+            Record("asdf", removal_in=(1, 0, 0), removal_in_py=(2, 0, 0)) == 
list(r)[0]
+        )
+
+
+def test_Record_str():
+    assert "removal in version=1.0.2, removal in python=3.0.2, reason: blah" 
== str(
+        Record("blah", removal_in=(1, 0, 2), removal_in_py=(3, 0, 2))
+    )
+
+
+def test_RecordCallable_str():
+    assert (
+        "qualname='snakeoil.blah.foon', removal in version=2.0.3, reason: I 
said so"
+        == str(
+            RecordCallable(
+                "I said so", qualname="snakeoil.blah.foon", removal_in=(2, 0, 
3)
+            )
+        )
+    )

Reply via email to