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)
+ )
+ )
+ )