commit:     6142a34ed03237790cdd8e190388de2956095198
Author:     Brian Harring <ferringb <AT> gmail <DOT> com>
AuthorDate: Sun Dec  7 17:31:19 2025 +0000
Commit:     Brian Harring <ferringb <AT> gmail <DOT> com>
CommitDate: Sun Dec  7 20:22:10 2025 +0000
URL:        
https://gitweb.gentoo.org/proj/pkgcore/snakeoil.git/commit/?id=6142a34e

feat: deprecated can now attach metadata to use for tracking removals.

The point of deprecations should not just be "tell downstream devs"-
it is explicitly metadata within the codebase that can be leveraged
to track if there is deprecated code still in the codebase that is
past it's expiry date.

This does that, and is designed such that each codebase creates it's
own `deprecated` Registry which keeps their deprecations internal to
them.

In the follow on commits I'll add logic to leverage this metadata
and update the codebase to enrich the existing deprecations.

Finally: store this in _internals.  This isn't intended for public
consumption, thus put it there.  Other packages- pytest for example-
use _pytest in the global scope.  We can do so, I'm just starting
the work via keeping it in our package namespace for now.

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

 src/snakeoil/_internals.py               |   4 +
 src/snakeoil/bash.py                     |   2 +-
 src/snakeoil/demandload.py               |   5 +-
 src/snakeoil/deprecation.py              | 146 ++++++++++++++++++++++---------
 src/snakeoil/klass/__init__.py           |   8 +-
 src/snakeoil/klass/_deprecated.py        |   4 +-
 src/snakeoil/klass/properties.py         |   2 -
 src/snakeoil/modules.py                  |   2 +-
 src/snakeoil/osutils/__init__.py         |   2 +-
 src/snakeoil/sequences.py                |   6 +-
 src/snakeoil/test/eq_hash_inheritance.py |   2 +-
 src/snakeoil/test/mixins.py              |   2 +-
 src/snakeoil/test/modules.py             |   2 +-
 src/snakeoil/test/slot_shadowing.py      |   4 +-
 tests/test_demandload.py                 |   2 +-
 tests/test_deprecation.py                | 105 ++++++++++++++++++++++
 tests/test_modules.py                    |   6 +-
 tests/test_osutils.py                    |   6 +-
 tests/test_sequences.py                  |   4 +-
 19 files changed, 245 insertions(+), 69 deletions(-)

diff --git a/src/snakeoil/_internals.py b/src/snakeoil/_internals.py
new file mode 100644
index 0000000..ff317ae
--- /dev/null
+++ b/src/snakeoil/_internals.py
@@ -0,0 +1,4 @@
+__all__ = ("deprecated",)
+from snakeoil.deprecation import Registry
+
+deprecated = Registry("snakeoil")

diff --git a/src/snakeoil/bash.py b/src/snakeoil/bash.py
index f682a8c..dfa9a2d 100644
--- a/src/snakeoil/bash.py
+++ b/src/snakeoil/bash.py
@@ -10,7 +10,7 @@ libtool .la files that are bash compatible, but 
non-executable.
 
 from shlex import shlex
 
-from snakeoil.deprecation import deprecated
+from snakeoil._internals import deprecated
 
 from .delayed import regexp
 from .fileutils import readlines

diff --git a/src/snakeoil/demandload.py b/src/snakeoil/demandload.py
index 4317add..af4c4ab 100644
--- a/src/snakeoil/demandload.py
+++ b/src/snakeoil/demandload.py
@@ -3,8 +3,9 @@ __all__ = ("demand_compile_regexp",)
 import sys
 import typing
 
+from snakeoil._internals import deprecated
+
 from .delayed import regexp
-from .deprecation import deprecated, deprecation_frame_depth
 
 
 @deprecated("snakeoil.klass.demand_compile_regexp has moved to 
snakeoil.delayed.regexp")
@@ -16,6 +17,6 @@ def demand_compile_regexp(
     The mechanism of injecting into the scope is deprecated; move to 
snakeoil.delayed.regexp.
     """
     if scope is None:
-        scope = sys._getframe(deprecation_frame_depth).f_globals
+        scope = sys._getframe(deprecated.stacklevel + 1).f_globals
     delayed = regexp(pattern, flags)
     scope[name] = delayed

diff --git a/src/snakeoil/deprecation.py b/src/snakeoil/deprecation.py
index 6aca45e..fa2d7c6 100644
--- a/src/snakeoil/deprecation.py
+++ b/src/snakeoil/deprecation.py
@@ -1,59 +1,127 @@
-__all__ = ("deprecated",)
+"""
+Deprecation related functionality.
 
-import functools
+This provides both a compatibility shim over python versions lacking
+warnings.deprecated, while also allowing some very basic extra metadata
+to be attached to the deprecation, and tracking all deprecations created
+by that registry.  This allows tests to do introspection for deprecations
+that can now be removed.
+
+"""
+
+__all__ = ("Registry", "Record", "suppress_deprecations")
+
+
+import contextlib
+import dataclasses
+import sys
 import typing
 import warnings
-from contextlib import contextmanager
 
 T = typing.TypeVar("T")
 P = typing.ParamSpec("P")
 
-_import_failed = False
-deprecation_frame_depth = 1  # some old code does "reach up the stack" tricks. 
 Thus it has to know how far up to climb.
-try:
-    from warnings import deprecated  # pyright: 
ignore[reportAttributeAccessIssue]
+Version: typing.TypeAlias = tuple[int, ...] | None
+warning_category: typing.TypeAlias = type[Warning]
 
-    deprecation_frame_depth = 2
-except ImportError:
-    _import_failed = True
-    import typing
 
-    def deprecated(_message: str):
-        """
-        This is a noop; deprecation warnings are disabled for pre python
-        3.13.
-        """
[email protected](slots=True, frozen=True)
+class Record:
+    thing: typing.Callable
+    msg: str
+    removal_in: Version = None
+    removal_in_py: Version = None
+    category: warning_category = DeprecationWarning
 
-        def f(thing):
-            return thing
 
-        return f
+# When py3.13 is the min, add a defaulted generic of Record in this, and
+# deprecated the init record_class argument.
+class Registry:
+    """Deprecated notice creation and tracking of deprecations
 
+    This is a no-op for python<3.13 since it's internally built around 
warnings.deprecated.
+    It can be used for compatibility for this reason, and .is_enabled reflects 
if it's
+    actually able to create deprecations, or if it's just in no-op 
compatibility mode.
 
-@contextmanager
-def suppress_deprecation_warning():
+    :cvar project: which project these deprecations are for.  This is used as 
a way to
+      restrict analysis of deprecation metadata for the codebase.
+    :cvar frame_depth: warnings issued have to be issued at the frame that 
trigged the warning.
+      If you have a deprecated function that reaches up the stack to 
manipulate a frames scope, this
+      is the depth to subtract, the frames from this issuing a deprecation.
+      Any subclasses that override __call__ must adjust this value.
     """
-    Used for suppressing all deprecation warnings beneath this
 
-    Use this for known deprecated code that is already addressed, but
-    just waiting to die.  Deprecated code calling deprecated code, 
specifically.
-    """
-    if _import_failed:
-        # noop.
-        yield
-    else:
-        # see 
https://docs.python.org/3/library/warnings.html#temporarily-suppressing-warnings
+    __slots__ = ("project", "_deprecations", "record_class")
+
+    record_class: type[Record]
+
+    is_enabled: typing.ClassVar[bool] = sys.version_info >= (3, 13, 0)
+    _deprecated_callable: typing.Callable | None
+
+    # Certain nasty python code that is deprecated lookups up the stack to do
+    # scope manipulation; document the number of frames we add if we're 
interposed
+    # between their target scope and their execution.
+    stacklevel: typing.ClassVar[int] = 1 if is_enabled else 0
+
+    if is_enabled:
+        from warnings import deprecated as _deprecated_callable
+
+    def __init__(self, project: str, /, *, record_class: type[Record] = 
Record):
+        self.project = project
+        # TODO: py3.13, change this to T per the cvar comments
+        self.record_class = record_class
+        self._deprecations: list[Record] = []
+        super().__init__()
+
+    def __call__(
+        self,
+        msg: str,
+        /,
+        *,
+        removal_in: Version = None,
+        removal_in_py: Version = None,
+        category: warning_category = DeprecationWarning,
+        **kwargs,
+    ):
+        """Decorate a callable with a deprecation notice, registering it in 
the internal list of deprecations"""
+
+        def f(thing):
+            if not self.is_enabled:
+                return thing
+
+            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,
+                    removal_in=removal_in,
+                    removal_in_py=removal_in_py,
+                    **kwargs,
+                )
+            )
+            return result
+
+        return f
+
+    @staticmethod
+    @contextlib.contextmanager
+    def suppress_deprecations(
+        category: warning_category = DeprecationWarning,
+    ):
+        """Suppress deprecations within this block.  Usable as a 
contextmanager or decorator"""
         with warnings.catch_warnings():
-            warnings.simplefilter(action="ignore", category=DeprecationWarning)
+            warnings.simplefilter(action="ignore", category=category)
             yield
 
+    # TODO: py3.13, change this to T per the cvar comments
+    def __iter__(self) -> typing.Iterator[Record]:
+        return iter(self._deprecations)
 
-def suppress_deprecations(thing: typing.Callable[P, T]) -> typing.Callable[P, 
T]:
-    """Decorator to suppress all deprecation warnings within the callable"""
-
-    @functools.wraps(thing)
-    def f(*args, **kwargs) -> T:
-        with suppress_deprecation_warning():
-            return thing(*args, **kwargs)
 
-    return f
+suppress_deprecations = Registry.suppress_deprecations

diff --git a/src/snakeoil/klass/__init__.py b/src/snakeoil/klass/__init__.py
index a1a7d09..250532a 100644
--- a/src/snakeoil/klass/__init__.py
+++ b/src/snakeoil/klass/__init__.py
@@ -43,7 +43,7 @@ import typing
 from collections import deque
 from operator import attrgetter
 
-from snakeoil.deprecation import deprecated as warn_deprecated
+from snakeoil._internals import deprecated
 from snakeoil.sequences import unique_stable
 
 from ..caching import WeakInstMeta
@@ -295,7 +295,7 @@ class GenericRichComparison(GenericEquality):
         return not self.__lt__(value, 
attr_comparison_override=attr_comparison_override)
 
 
-@warn_deprecated(
+@deprecated(
     "generic_equality metaclass usage is deprecated; inherit from 
snakeoil.klass.GenericEquality instead."
 )
 def generic_equality(
@@ -344,7 +344,7 @@ def generic_equality(
     return real_type(name, bases, scope)
 
 
-@warn_deprecated(
+@deprecated(
     "snakeoil.klass.chained_getter is deprecated.  Use operator.attrgetter 
instead."
 )
 class chained_getter(
@@ -378,7 +378,7 @@ class chained_getter(
         return self.getter(obj)
 
 
-static_attrgetter = warn_deprecated(
+static_attrgetter = deprecated(
     "snakeoil.klass.static_attrgetter is deprecated.  Use operator.attrgetter 
instead"
 )(chained_getter)
 

diff --git a/src/snakeoil/klass/_deprecated.py 
b/src/snakeoil/klass/_deprecated.py
index 79931c3..9f55625 100644
--- a/src/snakeoil/klass/_deprecated.py
+++ b/src/snakeoil/klass/_deprecated.py
@@ -5,7 +5,7 @@ __all__ = ("immutable_instance", "inject_immutable_instance", 
"ImmutableInstance
 import inspect
 import typing
 
-from snakeoil.deprecation import deprecated, suppress_deprecation_warning
+from snakeoil._internals import deprecated
 
 
 @deprecated("Use snakeoil.klass.meta.Immutable* metaclasses instead")
@@ -17,7 +17,7 @@ def immutable_instance(
     It still is possible to do object.__setattr__ to get around it during
     initialization, but usage of this class effectively prevents accidental
     modification, instead requiring explicit modification."""
-    with suppress_deprecation_warning():
+    with deprecated.suppress_deprecations():
         inject_immutable_instance(scope)
     return real_type(name, bases, scope)
 

diff --git a/src/snakeoil/klass/properties.py b/src/snakeoil/klass/properties.py
index 1142060..dcd1852 100644
--- a/src/snakeoil/klass/properties.py
+++ b/src/snakeoil/klass/properties.py
@@ -12,8 +12,6 @@ __all__ = (
 import operator
 import typing
 
-from snakeoil.deprecation import deprecated
-
 from ..currying import post_curry
 
 

diff --git a/src/snakeoil/modules.py b/src/snakeoil/modules.py
index d01cd09..7c10122 100644
--- a/src/snakeoil/modules.py
+++ b/src/snakeoil/modules.py
@@ -6,7 +6,7 @@ __all__ = ("FailedImport", "load_attribute", "load_any")
 
 from importlib import import_module
 
-from snakeoil.deprecation import deprecated
+from snakeoil._internals import deprecated
 
 
 class FailedImport(ImportError):

diff --git a/src/snakeoil/osutils/__init__.py b/src/snakeoil/osutils/__init__.py
index 1628097..7251b62 100644
--- a/src/snakeoil/osutils/__init__.py
+++ b/src/snakeoil/osutils/__init__.py
@@ -25,7 +25,7 @@ from stat import (
     S_ISREG,
 )
 
-from snakeoil.deprecation import deprecated
+from snakeoil._internals import deprecated
 
 listdir = deprecated("snakeoil.osutils.listdir is deprecated.  Use 
os.listdir")(
     lambda *a, **kw: os.listdir(*a, **kw)

diff --git a/src/snakeoil/sequences.py b/src/snakeoil/sequences.py
index 2d31721..91cb07d 100644
--- a/src/snakeoil/sequences.py
+++ b/src/snakeoil/sequences.py
@@ -24,7 +24,7 @@ from typing import (
     overload,
 )
 
-from snakeoil.deprecation import deprecated, suppress_deprecation_warning
+from snakeoil._internals import deprecated
 
 from .iterables import expandable_chain
 
@@ -43,7 +43,7 @@ def unstable_unique(sequence):
     except TypeError:
         # if it doesn't support len, assume it's an iterable
         # and fallback to the slower stable_unique
-        with suppress_deprecation_warning():
+        with deprecated.suppress_deprecations():
             return stable_unique(sequence)
     # assume all elements are hashable, if so, it's linear
     try:
@@ -83,7 +83,7 @@ def stable_unique(iterable: Iterable[T]) -> list[T]:
     For performance reasons, only use this if you really do need to preserve
     the ordering.
     """
-    with suppress_deprecation_warning():
+    with deprecated.suppress_deprecations():
         return list(iter_stable_unique(iterable))
 
 

diff --git a/src/snakeoil/test/eq_hash_inheritance.py 
b/src/snakeoil/test/eq_hash_inheritance.py
index 9adf994..66c2966 100644
--- a/src/snakeoil/test/eq_hash_inheritance.py
+++ b/src/snakeoil/test/eq_hash_inheritance.py
@@ -1,6 +1,6 @@
 __all__ = ("Test",)
 
-from ..deprecation import deprecated
+from snakeoil._internals import deprecated
 
 
 @deprecated(

diff --git a/src/snakeoil/test/mixins.py b/src/snakeoil/test/mixins.py
index 01dcfc2..99d3b9e 100644
--- a/src/snakeoil/test/mixins.py
+++ b/src/snakeoil/test/mixins.py
@@ -3,8 +3,8 @@ import os
 import stat
 import sys
 
+from snakeoil._internals import deprecated
 from snakeoil.compatibility import IGNORED_EXCEPTIONS
-from snakeoil.deprecation import deprecated
 
 
 @deprecated(

diff --git a/src/snakeoil/test/modules.py b/src/snakeoil/test/modules.py
index 4b5f249..e96f708 100644
--- a/src/snakeoil/test/modules.py
+++ b/src/snakeoil/test/modules.py
@@ -1,5 +1,5 @@
 __all__ = ("ExportedModules",)
-from snakeoil.deprecation import deprecated
+from snakeoil._internals import deprecated
 
 
 @deprecated("ExportedModules does nothing.  Use 
snakeoil.test.code_quality.Modules")

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

diff --git a/tests/test_demandload.py b/tests/test_demandload.py
index 12daf1a..ffcd671 100644
--- a/tests/test_demandload.py
+++ b/tests/test_demandload.py
@@ -7,7 +7,7 @@ from snakeoil import demandload, deprecation
 
 class TestDemandCompileRegexp:
     def test_demand_compile_regexp(self):
-        with deprecation.suppress_deprecation_warning():
+        with deprecation.suppress_deprecations():
             scope = {}
             demandload.demand_compile_regexp("foo", "frob", scope=scope)
             assert list(scope.keys()) == ["foo"]

diff --git a/tests/test_deprecation.py b/tests/test_deprecation.py
new file mode 100644
index 0000000..6a94697
--- /dev/null
+++ b/tests/test_deprecation.py
@@ -0,0 +1,105 @@
+import dataclasses
+import sys
+import warnings
+
+import pytest
+
+from snakeoil.deprecation import Record, Registry, suppress_deprecations
+
+requires_enabled = pytest.mark.skipif(
+    not Registry.is_enabled, reason="requires python >=3.13.0"
+)
+
+
+class TestRegistry:
+    def test_is_enabled(self):
+        assert (sys.version_info >= (3, 13, 0)) == Registry.is_enabled
+
+    @requires_enabled
+    def test_it(self):
+        r = Registry("tests")
+        assert "tests" == r.project
+        assert [] == list(r)
+
+        def f(x: int) -> int:
+            return x + 1
+
+        f2 = r("test1")(f)
+        assert f2 is not f
+        assert 1 == len(list(r))
+        assert Record(f, "test1", None, None, DeprecationWarning) == list(r)[0]
+
+        with r.suppress_deprecations():
+            assert 2 == f2(1)
+        if Registry.is_enabled:
+            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("test3", removal_in_py=(4, 0))(f)
+        assert (Record(f, "test3", None, (4, 0), DeprecationWarning)) == 
list(r)[-1]
+
+        class MyDeprecation(DeprecationWarning): ...
+
+        r("test4", category=MyDeprecation)(f)
+        assert (Record(f, "test4", None, None, MyDeprecation)) == list(r)[-1]
+
+    @pytest.mark.skipif(
+        Registry.is_enabled, reason="test is only for python 3.12 and lower"
+    )
+    def test_disabled(self):
+        r = Registry("tests")
+
+        def f(): ...
+
+        assert f is r("asdf")(f)
+        assert [] == list(r)
+
+    def test_suppress_deprecations(self):
+        # assert the convienence function and that we're just reusing the 
existing.
+        assert suppress_deprecations is Registry.suppress_deprecations
+
+        def f(category=DeprecationWarning):
+            warnings.warn("deprecation warning was not suppressed", 
category=category)
+
+        with pytest.warns() as capture:
+            with suppress_deprecations():
+                f()
+            # It's also usable as a decorator
+            suppress_deprecations()(f)()
+            # pytest.warns requires at least one warning.
+            warnings.warn("only allowed warning")
+        assert 1 == len(capture.list)
+        assert "deprecation warning was not suppressed" not in 
str(capture.list[0])
+
+    @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
+
+        @dataclasses.dataclass(slots=True, frozen=True, kw_only=True)
+        class MyRecord(Record):
+            extra_val1: int = 1
+            extra_val2: int = 2
+
+        def f(): ...
+
+        r = Registry("test", record_class=MyRecord)
+
+        r("asdf", extra_val1=3, extra_val2=4)(f)
+        assert 1 == len(list(r))
+        assert (
+            MyRecord(
+                f,
+                "asdf",
+                None,
+                None,
+                DeprecationWarning,
+                extra_val1=3,
+                extra_val2=4,
+            )
+            == list(r)[0]
+        )

diff --git a/tests/test_modules.py b/tests/test_modules.py
index 775e078..6af704b 100644
--- a/tests/test_modules.py
+++ b/tests/test_modules.py
@@ -34,7 +34,7 @@ class TestModules:
         sys.modules.pop("mod_horked", None)
         sys.modules.pop("mod_testpack.mod_horked", None)
 
-    @suppress_deprecations
+    @suppress_deprecations()
     def test_load_attribute(self):
         # already imported
         assert modules.load_attribute("sys.path") is sys.path
@@ -60,7 +60,7 @@ class TestModules:
         with pytest.raises(modules.FailedImport):
             modules.load_attribute("mod_testpack.mod_test3")
 
-    @suppress_deprecations
+    @suppress_deprecations()
     def test_load_any(self):
         # import an already-imported module
         assert modules.load_any("snakeoil.modules") is modules
@@ -89,7 +89,7 @@ class TestModules:
         with pytest.raises(modules.FailedImport):
             modules.load_any("mod_testpack.mod_test3")
 
-    @suppress_deprecations
+    @suppress_deprecations()
     def test_broken_module(self):
         for func in [modules.load_any]:
             with pytest.raises(modules.FailedImport):

diff --git a/tests/test_osutils.py b/tests/test_osutils.py
index 6718097..a4843ca 100644
--- a/tests/test_osutils.py
+++ b/tests/test_osutils.py
@@ -11,7 +11,7 @@ import pytest
 
 from snakeoil import osutils
 from snakeoil.contexts import Namespace
-from snakeoil.deprecation import suppress_deprecation_warning, 
suppress_deprecations
+from snakeoil.deprecation import suppress_deprecations
 from snakeoil.osutils import listdir_dirs, listdir_files, sizeof_fmt, 
supported_systems
 from snakeoil.osutils.mount import MNT_DETACH, MS_BIND, mount, umount
 
@@ -180,7 +180,7 @@ class TestEnsureDirs:
 
 
 class TestAbsSymlink:
-    @suppress_deprecations
+    @suppress_deprecations()
     def test_abssymlink(self, tmp_path):
         target = tmp_path / "target"
         linkname = tmp_path / "link"
@@ -189,7 +189,7 @@ class TestAbsSymlink:
         assert osutils.abssymlink(linkname) == str(target)
 
 
-@suppress_deprecation_warning()
+@suppress_deprecations()
 class Test_Native_NormPath:
     func = staticmethod(osutils.normpath)
 

diff --git a/tests/test_sequences.py b/tests/test_sequences.py
index 0fad4c2..d995535 100644
--- a/tests/test_sequences.py
+++ b/tests/test_sequences.py
@@ -5,7 +5,7 @@ from operator import itemgetter
 import pytest
 
 from snakeoil import sequences
-from snakeoil.deprecation import suppress_deprecation_warning
+from snakeoil.deprecation import suppress_deprecations
 from snakeoil.sequences import (
     iter_stable_unique,
     split_elements,
@@ -19,7 +19,7 @@ class UnhashableComplex(complex):
         raise TypeError
 
 
-@suppress_deprecation_warning()
+@suppress_deprecations()
 class TestStableUnique:
     def common_check(self, func):
         # silly

Reply via email to