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