Skip to content

Commit 0bcd346

Browse files
Bordaclaudepre-commit-ci[bot]Copilotgithub-advanced-security[bot]
authored
chore: autouse state reset + backward-compat shim coverage (#189)
- Added autouse test-state reset fixture for deprecated wrapper warning counters - Added coverage for backward-compatible audit shims and public deprecated aliases - Added one-time misconfiguration warning coverage - Added `args_extra` ARGS_REMAP call-plan coverage - Added `num_warns=1` budget-exhaustion regression coverage - Added sync `@deprecated` caller-frame stacklevel coverage - Completed `DeprecationStatus` lifecycle unit coverage - Removed global pytest warning suppression so unexpected warning leaks are visible - Fixed proxy warning stacklevel attribution to caller frames - Fixed `no_warning_call` stacklevel attribution with class-based context manager behavior - Moved `no_warning_call` deprecation warning to instantiation time - Added chain-detection coverage for target chains of depth three or more - Added stacked-source short-circuit coverage in `_build_call_plan` - Deferred test fixture imports to avoid doctest/site-package import mismatch - Reworked wrapper-state iteration to avoid proxy side effects and warning-budget exhaustion - Added direct instantiation coverage for `DeprecatedCallableInfo` - Added no-warning-call instantiation-without-entering coverage - Improved Windows-portable filename assertions - Documented proxy stream stacklevel constraints - Clarified test-fixture reset limits and `warned_misconfigured` hazards - Expanded `no_warning_call` docstring with args and runnable example - Cleaned misleading test docs, lint findings, and reverted non-surviving autofix --------- Co-authored-by: Claude Code <noreply@anthropic.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
1 parent a76ddde commit 0bcd346

13 files changed

Lines changed: 630 additions & 26 deletions

pyproject.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,6 @@ ini_options.addopts = [
9797
"--strict-markers",
9898
"--doctest-modules",
9999
"--color=yes",
100-
"--disable-pytest-warnings",
101100
]
102101
# filterwarnings = ["error::FutureWarning"]
103102
# Suppress only the specific legacy-sentinel warnings emitted by `TargetMode.from_legacy()`

src/deprecate/proxy.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@
4040
)
4141
from deprecate.docstring.inject import _update_docstring_with_deprecation, normalize_docstring_style
4242

43+
#: Stacklevel from inside ``_warn`` to the caller's frame.
44+
#: Chain: ``caller → __getattr__/__getitem__/__iter__/__call__ → _warn → stream → warnings.warn``.
45+
#: From ``warnings.warn`` upwards: ``1=_warn``, ``2=accessor`` (e.g. ``__getattr__``), ``3=caller``.
46+
_DEFAULT_STACKLEVEL_TO_CALLER: int = 3
47+
4348

4449
class _DeprecatedProxy:
4550
"""Transparent proxy that emits deprecation warnings on attribute and item access.
@@ -60,7 +65,10 @@ class _DeprecatedProxy:
6065
remove_in: Version string when the object will be removed.
6166
num_warns: Maximum number of warnings to emit. ``1`` (default) warns once; ``-1`` warns on every access.
6267
stream: Callable used to emit warnings. Defaults to :data:`~deprecate.deprecation.deprecation_warning`
63-
(:class:`FutureWarning`). Pass ``None`` to suppress warnings.
68+
(:class:`FutureWarning`). Pass ``None`` to suppress warnings. **Note:** the built-in
69+
stacklevel budget assumes *stream* is :func:`warnings.warn` itself or a C-level
70+
:func:`functools.partial` of it; a Python-defined wrapper interposes an extra frame and
71+
the warning will appear to originate inside :mod:`deprecate.proxy` rather than the caller.
6472
template_mgs: Optional custom warning message template that overrides the built-in templates. When ``None``
6573
(default), the built-in template for the active scenario is used (callable-target, no-target, or
6674
per-argument). See :func:`~deprecate.proxy.deprecated_class` for the available ``%``-style placeholders.
@@ -245,7 +253,13 @@ def _warn(self, *, arg_name: Optional[str] = None) -> None:
245253
"deprecated_in": dep.deprecated_in,
246254
"remove_in": dep.remove_in,
247255
}
248-
stream(msg)
256+
# Route the warning to the caller's frame rather than ``proxy.py``. Mirrors the
257+
# ``_raise_warn`` fallback in ``deprecation.py``: when ``stream`` does not accept a
258+
# ``stacklevel`` kwarg (e.g. ``print``, custom callables), fall back to a positional call.
259+
try:
260+
stream(msg, stacklevel=_DEFAULT_STACKLEVEL_TO_CALLER)
261+
except TypeError:
262+
stream(msg)
249263
if arg_name is not None:
250264
cfg.warned_args[arg_name] = cfg.warned_args.get(arg_name, 0) + 1
251265
else:

src/deprecate/utils.py

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from collections.abc import Generator
2424
from contextlib import contextmanager
2525
from functools import lru_cache
26+
from types import TracebackType
2627
from typing import Any, Callable, Optional, Union
2728

2829

@@ -174,22 +175,56 @@ def assert_no_warnings(warning_type: Optional[type[Warning]] = None, match: Opti
174175
)
175176

176177

177-
@contextmanager
178-
def no_warning_call(warning_type: Optional[type[Warning]] = None, match: Optional[str] = None) -> Generator:
178+
class no_warning_call: # noqa: N801 - kept for backward compatibility with prior snake_case API
179179
"""Deprecated alias for :func:`~deprecate.utils.assert_no_warnings`.
180180
181181
This context manager is kept for backward compatibility so that existing imports like
182182
``from deprecate.utils import no_warning_call`` continue to work until v1.0.
183183
184+
Warning fires at instantiation — the ``no_warning_call(...)`` call line receives the
185+
deprecation notice, regardless of how the context manager is subsequently used.
186+
187+
Args:
188+
warning_type: The :class:`Warning` subclass to watch for. Defaults to :class:`Warning`
189+
(all warning categories).
190+
match: Optional substring that must appear in the warning message. When ``None`` (default),
191+
any warning of the right category triggers an :class:`AssertionError`.
192+
193+
Examples:
194+
>>> import warnings
195+
>>> with warnings.catch_warnings():
196+
... warnings.simplefilter("ignore", DeprecationWarning)
197+
... with no_warning_call():
198+
... pass # no AssertionError means no warnings were emitted
199+
184200
"""
185-
warnings.warn(
186-
"`deprecate.utils.no_warning_call` is deprecated in `0.6` and will be removed in `1.0`; "
187-
"use `deprecate.utils.assert_no_warnings` instead.",
188-
DeprecationWarning,
189-
stacklevel=2,
190-
)
191-
with assert_no_warnings(warning_type=warning_type, match=match):
192-
yield
201+
202+
def __init__(self, warning_type: Optional[type[Warning]] = None, match: Optional[str] = None) -> None:
203+
"""Emit the alias-deprecation warning and capture args for the no-warning assertion."""
204+
warnings.warn(
205+
"`deprecate.utils.no_warning_call` is deprecated in `0.6` and will be removed in `1.0`; "
206+
"use `deprecate.utils.assert_no_warnings` instead.",
207+
DeprecationWarning,
208+
stacklevel=2,
209+
)
210+
self._warning_type = warning_type
211+
self._match = match
212+
self._inner: Any = None # ``assert_no_warnings`` returns a ``_GeneratorContextManager``
213+
214+
def __enter__(self) -> None:
215+
"""Enter the underlying ``assert_no_warnings`` context."""
216+
self._inner = assert_no_warnings(warning_type=self._warning_type, match=self._match)
217+
self._inner.__enter__()
218+
219+
def __exit__(
220+
self,
221+
exc_type: Optional[type[BaseException]],
222+
exc_val: Optional[BaseException],
223+
exc_tb: Optional[TracebackType],
224+
) -> Optional[bool]:
225+
"""Forward exit to the underlying ``assert_no_warnings`` so its AssertionError still surfaces."""
226+
assert self._inner is not None # noqa: S101 — internal invariant: __exit__ only runs after __enter__
227+
return self._inner.__exit__(exc_type, exc_val, exc_tb)
193228

194229

195230
def void(*args: Any, **kwrgs: Any) -> Any: # noqa: ANN401

tests/collection_chains.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,3 +316,22 @@ def caller_sum_direct(a: int, b: int = 3) -> int:
316316
317317
"""
318318
return void(a, b)
319+
320+
321+
# Three-link chain: ``caller_three_hop_sum`` → ``caller_sum_via_depr_sum`` (deprecated) →
322+
# ``decorated_sum`` (deprecated) → ``base_sum_kwargs`` (final). The audit only inspects the
323+
# immediate target — ``caller_sum_via_depr_sum`` is itself deprecated with a non-stacking
324+
# (callable) target, so ``caller_three_hop_sum`` reports ``ChainType.TARGET``. Used to pin that
325+
# chain detection works at depths greater than 2 (existing fixtures only cover 2-hop chains).
326+
@deprecated(target=caller_sum_via_depr_sum, deprecated_in="1.6", remove_in="2.6")
327+
def caller_three_hop_sum(a: int, b: int = 5) -> int:
328+
"""Three-link deprecation chain for chain-depth coverage.
329+
330+
Examples:
331+
Each hop is deprecated:
332+
``caller_three_hop_sum`` → ``caller_sum_via_depr_sum`` → ``decorated_sum`` →
333+
``base_sum_kwargs`` (final). Fix: collapse all hops and point directly to
334+
``base_sum_kwargs``.
335+
336+
"""
337+
return void(a, b)

tests/conftest.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
"""Project-wide pytest configuration.
2+
3+
This module provides an :func:`pytest.fixture` (``autouse=True``) that resets the mutable
4+
``_WrapperState`` on every module-level deprecated wrapper exported from
5+
:mod:`tests.collection_deprecate` before each test runs.
6+
7+
Why this matters
8+
----------------
9+
10+
Module-level :func:`@deprecated <deprecate.deprecation.deprecated>` wrappers in
11+
``tests/collection_deprecate.py`` are singletons whose warning counters (``warned_calls`` and
12+
``warned_args``) persist across tests once that module is imported. When a wrapper is
13+
configured with ``num_warns=1`` (the default), the first test that exercises it exhausts the
14+
budget; any subsequent test that asserts "no warning fired" via patterns like
15+
``no_warning_call`` will silently pass for the wrong reason — the counter is spent, not the
16+
underlying behaviour.
17+
18+
Two file-scoped ``autouse`` fixtures already cover the **async** and **generator** wrappers
19+
in ``tests/integration/test_callable_kinds.py`` (see ``_reset_gen_state``, ``_reset_async_state``,
20+
``_reset_async_gen_state``). This conftest extends that reset to every other wrapper that
21+
lives at the module level of :mod:`tests.collection_deprecate`, including plain synchronous
22+
functions. ``_DeprecatedProxy`` instances (created by ``deprecated_class``) are **not** covered —
23+
they store state in ``_cfg.warned`` rather than ``_state``; see the *What this fixture does not
24+
cover* section below.
25+
26+
Reset semantics
27+
---------------
28+
29+
Only the *counter-style* state is cleared:
30+
31+
* ``warned_calls`` → ``0``
32+
* ``warned_args`` → empty :class:`dict`
33+
34+
The following are intentionally preserved:
35+
36+
* ``warned_misconfigured`` is **not** reset — it implements a one-time UserWarning per
37+
wrapper lifetime (see ``test_callable_kinds.py`` autouse-fixture docstrings). Resetting
38+
it would falsify that contract. Tests that assert misconfig warnings **emit** must explicitly
39+
reset ``_state.warned_misconfigured = False`` in their own setup — do not rely on fixture
40+
ordering. Symmetrically, tests that assert misconfig warnings are **not** emitted against a
41+
shared misconfigured wrapper depend silently on whether a prior test already consumed the
42+
one-time slot: ``assert_no_warnings(UserWarning)`` may pass because the counter is spent, not
43+
because the wrapper is correctly configured. Both directions require explicit per-test setup.
44+
* ``called`` is **not** reset — it counts every invocation (including suppressed ones) and
45+
is not used to gate warning emission.
46+
47+
What this fixture does *not* cover
48+
-----------------------------------
49+
50+
Class-proxy state (``_ProxyConfig.warned``) on ``_DeprecatedProxy`` instances is **separate**
51+
from ``_state`` and is a real cross-test leak surface for proxies configured with
52+
``num_warns=1`` (e.g. ``_class_deprecation_enum``, ``_class_deprecation_dataclass``). Those
53+
proxies are reset by ``_ClassFormBase._reset_proxy_state`` in
54+
``tests/integration/test_classes.py``. New tests that use ``num_warns=1`` proxies from
55+
``collection_deprecate`` must either inherit ``_ClassFormBase`` or add their own proxy-state
56+
reset.
57+
58+
"""
59+
60+
from __future__ import annotations
61+
62+
import pytest
63+
64+
65+
def _iter_wrapper_states(module: object) -> list[object]:
66+
"""Return every module-level attribute of *module* that carries a ``_state`` attribute.
67+
68+
Uses ``vars(module)`` (direct ``__dict__`` lookup) and checks for ``_state`` via the
69+
instance ``__dict__`` rather than ``hasattr``, so ``_DeprecatedProxy.__getattr__`` is
70+
never triggered. This avoids emitting spurious ``FutureWarning``s and consuming
71+
``num_warns`` budgets during fixture traversal.
72+
73+
"""
74+
found: list[object] = []
75+
for obj in vars(module).values(): # type: ignore[arg-type]
76+
if "_state" in getattr(obj, "__dict__", {}):
77+
found.append(obj)
78+
return found
79+
80+
81+
@pytest.fixture(autouse=True)
82+
def _reset_collection_deprecate_state() -> None:
83+
"""Reset every shared module-level wrapper's warning counters before each test.
84+
85+
Runs before every test function (autouse). See module docstring for full motivation.
86+
The import is deferred to avoid loading ``tests.collection_deprecate`` at conftest
87+
parse time, which conflicts with ``--doctest-modules`` collection when pytest resolves
88+
``src/`` imports from the installed package rather than from ``src/``.
89+
90+
"""
91+
import tests.collection_deprecate as _collection_deprecate
92+
93+
for wrapper in _iter_wrapper_states(_collection_deprecate):
94+
state = wrapper._state # type: ignore[attr-defined]
95+
# Reset only the counter-style state. ``warned_misconfigured`` and ``called``
96+
# are intentionally NOT cleared — see module docstring.
97+
state.warned_calls = 0
98+
state.warned_args.clear()

tests/integration/test_audit.py

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,13 @@
1515
import tests.collection_chains as chain_module
1616
import tests.collection_deprecate as proxy_module
1717
import tests.collection_misconfigured as sample_module
18-
from deprecate import deprecated, validate_deprecation_expiry
18+
from deprecate import (
19+
DeprecatedCallableInfo,
20+
deprecated,
21+
find_deprecated_callables,
22+
validate_deprecated_callable,
23+
validate_deprecation_expiry,
24+
)
1925
from deprecate._types import DeprecationConfig
2026
from deprecate.audit import (
2127
ChainType,
@@ -443,6 +449,24 @@ def test_detects_chain_with_composed_arg_mappings(self, chain_issues: list) -> N
443449
assert info.chain_type is ChainType.TARGET
444450
assert info.deprecated_info.args_mapping == {"predictions": "preds", "labels": "truth"}
445451

452+
def test_detects_three_hop_target_chain(self, chain_issues: list) -> None:
453+
"""Chain depth > 2 (A → B → C → final, all deprecated) is detected at the outermost hop.
454+
455+
The detection logic inspects only the immediate target (it does not recursively walk the
456+
chain), so ``caller_three_hop_sum`` reports ``ChainType.TARGET`` because its target
457+
``caller_sum_via_depr_sum`` is itself deprecated. Each intermediate hop is reported
458+
independently when scanned — this test pins the outermost hop only, validating that
459+
depth ≥ 3 does not break chain detection on the leading wrapper.
460+
461+
"""
462+
by_name = {i.function: i for i in chain_issues}
463+
assert "caller_three_hop_sum" in by_name, "Three-hop chain fixture must be discovered by the audit"
464+
assert by_name["caller_three_hop_sum"].chain_type is ChainType.TARGET
465+
# Every intermediate hop is independently a deprecation chain — the audit reports
466+
# all three (outer, middle, and inner-via-decorated_sum already covered) when
467+
# scanning the module.
468+
assert "caller_sum_via_depr_sum" in by_name, "Middle hop must also be flagged as TARGET chain"
469+
446470
@pytest.mark.parametrize(
447471
"fn_pattern",
448472
[
@@ -844,3 +868,79 @@ def test_return_format(self) -> None:
844868
assert all(isinstance(msg, str) for msg in expired)
845869
for msg in expired:
846870
assert "Callable" in msg or "scheduled" in msg
871+
872+
873+
class TestBackwardCompatShims:
874+
"""Coverage for the three v0.6-era public aliases preserved in :mod:`deprecate.audit`.
875+
876+
The three shims are themselves wrapped with :func:`~deprecate.deprecation.deprecated` /
877+
:func:`~deprecate.proxy.deprecated_class` (``deprecated_in="0.6"``, ``remove_in="1.0"``),
878+
so calling them must (a) emit a :class:`FutureWarning` (the project default warning
879+
category) and (b) forward to the new-name implementation transparently.
880+
881+
Only the shim's own deprecation warning is asserted. The underlying audit helpers
882+
(:func:`~deprecate.audit.validate_deprecation_wrapper`, :func:`~deprecate.audit.find_deprecation_wrappers`)
883+
read ``__deprecated__`` metadata without invoking the deprecated callable, so no additional
884+
warning originates from the fixture functions themselves.
885+
886+
"""
887+
888+
def test_validate_deprecated_callable_emits_warning_and_forwards(self) -> None:
889+
"""``validate_deprecated_callable`` warns and returns the same result as the new name."""
890+
with warnings.catch_warnings(record=True) as recorded:
891+
warnings.simplefilter("always")
892+
result_old = validate_deprecated_callable(proxy_module.decorated_pow_self)
893+
894+
# The shim's own deprecation warning must appear in the record; identify it by message.
895+
shim_warns = [w for w in recorded if "validate_deprecated_callable" in str(w.message)]
896+
assert shim_warns, "Calling the shim must emit a FutureWarning about its own deprecation"
897+
assert shim_warns[0].category is FutureWarning
898+
899+
# Result must equal the underlying new-name implementation called on the same input.
900+
result_new = validate_deprecation_wrapper(proxy_module.decorated_pow_self)
901+
assert result_old == result_new
902+
903+
def test_find_deprecated_callables_emits_warning_and_forwards(self) -> None:
904+
"""``find_deprecated_callables`` warns and returns the same list as ``find_deprecation_wrappers``."""
905+
with warnings.catch_warnings(record=True) as recorded:
906+
warnings.simplefilter("always")
907+
result_old = find_deprecated_callables(proxy_module, recursive=False)
908+
909+
shim_warns = [w for w in recorded if "find_deprecated_callables" in str(w.message)]
910+
assert shim_warns, "Calling the shim must emit a FutureWarning about its own deprecation"
911+
assert shim_warns[0].category is FutureWarning
912+
913+
result_new = find_deprecation_wrappers(proxy_module, recursive=False)
914+
assert {r.function for r in result_old} == {r.function for r in result_new}
915+
916+
def test_deprecated_callable_info_alias_resolves(self) -> None:
917+
"""``DeprecatedCallableInfo`` is a :func:`deprecated_class` proxy aliasing ``DeprecationWrapperInfo``.
918+
919+
A real :class:`DeprecationWrapperInfo` instance (obtained from
920+
:func:`find_deprecation_wrappers`) must satisfy ``isinstance(_, DeprecatedCallableInfo)`` —
921+
the proxy implements ``__instancecheck__`` to delegate to the target class.
922+
923+
"""
924+
results = find_deprecation_wrappers(proxy_module, recursive=False)
925+
assert results, "Fixture module must contain at least one deprecated wrapper"
926+
# Filter to deterministic instance — first item from a non-empty list.
927+
info = results[0]
928+
assert isinstance(info, DeprecationWrapperInfo)
929+
assert isinstance(info, DeprecatedCallableInfo)
930+
931+
def test_deprecated_callable_info_instantiation_warns(self) -> None:
932+
"""Calling ``DeprecatedCallableInfo(...)`` directly emits a ``FutureWarning`` about its own deprecation."""
933+
# pytest introspection (inspect.hasattr '__wrapped__') may consume num_warns=1 before this
934+
# test body runs; reset the proxy's warn counter so the call below always fires the warning.
935+
DeprecatedCallableInfo._cfg.warned = 0 # type: ignore[attr-defined]
936+
with warnings.catch_warnings(record=True) as recorded:
937+
warnings.simplefilter("always")
938+
info = DeprecatedCallableInfo( # type: ignore[call-arg]
939+
module="tests",
940+
function="f",
941+
deprecated_info=DeprecationConfig(deprecated_in="1.0", remove_in="2.0"),
942+
)
943+
shim_warns = [w for w in recorded if "DeprecatedCallableInfo" in str(w.message)]
944+
assert shim_warns, "Calling DeprecatedCallableInfo(...) must emit FutureWarning about its own deprecation"
945+
assert shim_warns[0].category is FutureWarning
946+
assert isinstance(info, DeprecationWrapperInfo)

0 commit comments

Comments
 (0)