commit:     fa384111eeb216c24cc84d71364a4387fe2b67ee
Author:     Brian Harring <ferringb <AT> gmail <DOT> com>
AuthorDate: Sat Dec 13 07:36:03 2025 +0000
Commit:     Brian Harring <ferringb <AT> gmail <DOT> com>
CommitDate: Sat Dec 13 19:53:50 2025 +0000
URL:        
https://gitweb.gentoo.org/proj/pkgcore/snakeoil.git/commit/?id=fa384111

chore: rewrite suppress_deprecations to handle generators

Warnings filters are dynamic for the context, and generators resume
into the requesting context- not what they started in.  It's a known thing,
it's a gotcha that's a PITA.

Either I try to explain the problem in the docstring, or just write code
that suppresses it via injecting a generator 'shape' in front of the
actual generator to suppress, and flip the supression on/off around entry
and exit of the generator.

Tests were added, including asserting the python implementation behavior
this suppresses.

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

 src/snakeoil/deprecation.py | 128 +++++++++++++++++++++++++++++++++++++++-----
 tests/test_deprecation.py   |  90 +++++++++++++++++++++++++++++++
 2 files changed, 205 insertions(+), 13 deletions(-)

diff --git a/src/snakeoil/deprecation.py b/src/snakeoil/deprecation.py
index 380cbb6..8e8e0d2 100644
--- a/src/snakeoil/deprecation.py
+++ b/src/snakeoil/deprecation.py
@@ -17,8 +17,9 @@ when future conditions are met.
 __all__ = ("Registry", "RecordCallable", "suppress_deprecations")
 
 
-import contextlib
 import dataclasses
+import functools
+import inspect
 import sys
 import typing
 import warnings
@@ -26,6 +27,118 @@ import warnings
 T = typing.TypeVar("T")
 P = typing.ParamSpec("P")
 
+
+class suppress_deprecations:
+    """Suppress deprecations within this block.  Generators and async.Task 
require special care to function.
+
+    This cannot be used to decorate a generator function.  Using it within a 
generator requires explicit code flow for it to work correctly whilst not 
causing suppressions outside of the intended usage.
+
+    The cpython warnings filtering is designed around ContextVar- context 
specific
+    to a thread, an async.Task, etc.  Warnings filtering modifies a context 
var thus
+    suppressions are active only within that context.  Generators do *not* 
bind to any
+    context they started in- whenever they resume, it's resuming in the 
context of the thing
+    that resumed them.
+
+    Do not do this in a generator:
+    >>> def f():
+    ...   with suppress_deprecations():
+    ...     yield invoke_deprecated() # this will be suppressed, but leaks 
suppression to what consumed us.
+    ...
+    ...     # in resuming, we have no guarantee we're in the same context as 
before the yield, where our
+    ...     # suppression was added.
+    ...     yield invoke_deprecated() # this may or may not be suppressed.
+
+    You have two options.  If you do not need fine grained, wrap the 
generator; this class will interpose
+    between the generator and consumer and prevent this issue.  For example:
+    >>> @suppress_deprecations()
+    ... def f():
+    ...   yield invoke_deprecated()
+    ...   yield invoke_deprecated()
+
+    If you need the explicit form, use this:
+    >>> def f():
+    ...   with suppress_deprecations():
+    ...     value = invoke_deprecated() # this will be suppressed
+    ...   yield value # we do not force our suppression on the consumer of the 
generator
+    ...   with suppress_deprecations():
+    ...     another_value = invoke_deprecated()
+    ...   yield another_value
+    """
+
+    __slots__ = (
+        "_warning_ctx",
+        "kwargs",
+    )
+    _warnings_ctx: None | warnings.catch_warnings
+
+    def __init__(self, category=DeprecationWarning, **kwargs):
+        kwargs.setdefault("action", "ignore")
+        kwargs.setdefault("category", DeprecationWarning)
+        self.kwargs = kwargs
+        self._warning_ctx = None
+
+    def __enter__(self):
+        if self._warning_ctx is not None:
+            raise RuntimeError("this contextmanager has already been entered")
+        self._warning_ctx = warnings.catch_warnings(**self.kwargs)
+        return self._warning_ctx.__enter__()
+
+    def __exit__(self, exc_type, exc_value, traceback):
+        if (ctx := self._warning_ctx) is None:
+            raise RuntimeError("this contextmanager has already exited")
+        ret = ctx.__exit__(exc_type, exc_value, traceback)
+        self._warning_ctx = None
+        return ret
+
+    def __call__(self, thing: typing.Callable[P, T]) -> typing.Callable[P, T]:
+        # being used as a decorator.  We unfortunately need to see the actual 
call result
+        # to know if it's a generator requiring wrapping.
+        @functools.wraps(thing)
+        def inner(*args: P.args, **kwargs: P.kwargs) -> T:
+            # instantiate a new instance.  The callable may result in 
re-entrancy.
+            with (ctx := self.__class__(**self.kwargs)):
+                result = thing(*args, **kwargs)
+            if inspect.isgenerator(result):
+                return _GeneratorProxy(result, ctx)  # pyright: 
ignore[reportReturnType]
+            return result
+
+        return inner
+
+
+class _GeneratorProxy:
+    """Interposing generator.  Unfortunately this is required due to how 
coroutines work"""
+
+    __slots__ = (
+        "_gen",
+        "_ctx",
+    )
+
+    def __init__(self, gen: typing.Generator, ctx: suppress_deprecations):
+        self._gen = gen
+        self._ctx = ctx
+
+    def __iter__(self):
+        return self
+
+    def __next__(self):
+        with self._ctx:
+            return next(self._gen)
+
+    def send(self, val):
+        with self._ctx:
+            return self._gen.send(val)
+
+    def throw(self, *args):
+        return self._gen.throw(*args)
+
+    def close(self):
+        with self._ctx:
+            self._gen.close()
+
+    def __getattr__(self, attr):
+        return getattr(self._gen, attr)
+
+
 Version: typing.TypeAlias = tuple[int, int, int]
 warning_category: typing.TypeAlias = type[Warning]
 
@@ -194,15 +307,7 @@ class Registry:
             str(r), category=DeprecationWarning, stacklevel=self.stacklevel + 2
         )
 
-    @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=category)
-            yield
+    suppress_deprecations = staticmethod(suppress_deprecations)
 
     # TODO: py3.13, change this to T per the cvar comments
     def __iter__(self) -> typing.Iterator[Record]:
@@ -230,6 +335,3 @@ class Registry:
                 and python_version >= deprecation.removal_in_py
             ):
                 yield deprecation
-
-
-suppress_deprecations = Registry.suppress_deprecations

diff --git a/tests/test_deprecation.py b/tests/test_deprecation.py
index 33d55f3..1f06055 100644
--- a/tests/test_deprecation.py
+++ b/tests/test_deprecation.py
@@ -1,6 +1,8 @@
 import dataclasses
+import inspect
 import sys
 import warnings
+from logging import warning
 from pathlib import Path
 from textwrap import dedent
 
@@ -189,6 +191,94 @@ class TestRegistry:
         assert w.filename.endswith("/deprecated_import.py")
         assert 1 == w.lineno
 
+    def test_suppress_warnings_generators(self):
+        # See the docstring of suppress_warnings.  This asserts that the python
+        # implementation's generator state mutates the local context, rather 
than
+        # carrying it's only 'subcontext'.  IE, what it does will bleed out the
+        # warning suppression.
+
+        # assert no warning filters are going to screw up this test 
requirements
+        with pytest.warns():
+            warnings.warn("must be caught", DeprecationWarning)
+
+        # warnings.catch_warnings() cannot be used for these tests since they 
reset the filters to
+        # what the state of it's __enter__.  Meaning any warnings mutations 
within that block of the generator
+        # get undone if you do this:
+        # >>> with warnings.catch_warnings():
+        # ...   next(f) # it added it's suppressions
+        # ... blah # the suppressions from f() got undone by the __exit__ of 
catch_warnings
+        warnings_filters = warnings.filters[:]
+
+        def f():
+            with suppress_deprecations():
+                warnings.warn("will be caught", DeprecationWarning)
+                yield
+                warnings.warn(
+                    "may not be caught depending on context of resumption",
+                    category=DeprecationWarning,
+                )
+
+        i = f()
+        next(i)
+        assert warnings_filters != warnings.filters, (
+            f"generator context modifications were limited to the generator 
frame.  Is this pypi?  a python version >3.15?  See test for assumption notes.  
Expected {warnings_filters}, got {warnings.filters}"
+        )
+        # exhaust it to exit the generators suppression block.
+        for _ in i:
+            ...
+        assert warnings.filters == warnings_filters
+
+        # ... cool.  Now we've asserted the python behavior which is *why* we 
have a generator specific
+        # protection built into it.  Now to validate that works.
+
+        # simple case.  Just yields, not a coroutine
+        @suppress_deprecations()
+        def iterable():
+            warnings.warn("suppressed #1", DeprecationWarning)
+            yield 1
+            warnings.warn("not suppressed", UserWarning)
+            warnings.warn("suppressed #2", DeprecationWarning)
+            yield 2
+            warnings.warn("suppress #3", DeprecationWarning)
+
+        with warnings.catch_warnings(record=True) as w:
+            i = iterable()
+            assert 1 == next(i)
+
+            assert 0 == len(w)
+            with pytest.warns(UserWarning):
+                assert 2 == next(i)
+            assert 0 == len(w)
+            with pytest.raises(StopIteration):
+                next(i)
+        assert 0 == len(w)
+
+        # test coroutines.
+        @suppress_deprecations()
+        def coro():
+            warnings.warn("not suppressed", UserWarning)
+            warnings.warn("suppress #2", DeprecationWarning)
+
+            received = yield 1
+            assert "a1" == received
+            warnings.warn("suppress #3", DeprecationWarning)
+            received = yield 2
+            assert "a2" == received
+            warnings.warn("suppress #3", DeprecationWarning)
+            warnings.warn("not suppressed", UserWarning)
+
+        with warnings.catch_warnings(record=True) as w:
+            gen = coro()
+            assert 0 == len(w)  # shouldn't be started by that action alone
+            assert inspect.GEN_CREATED == inspect.getgeneratorstate(gen)
+            with pytest.warns(UserWarning):
+                assert 1 == next(gen)  # start it.
+            assert 2 == gen.send("a1")
+            with pytest.warns(UserWarning):
+                with pytest.raises(StopIteration):
+                    gen.send("a2")
+        assert 0 == len(w)
+
 
 def test_RecordModule_str():
     assert "module='foon.blah', removal in python=3.0.2, reason: why not" == 
str(

Reply via email to