Skip to content

Troubleshooting🔗

This page covers the most common problems encountered when using pyDeprecate, with direct answers and corrected code for each. If your issue is not listed here, see the "Still stuck?" section at the bottom.

ModuleNotFoundError: No module named 'pydeprecate'🔗

Q: I installed the package with pip install pyDeprecate but get ModuleNotFoundError: No module named 'pydeprecate' when I try to import it.

A: The install name and the import name are different. The package installs as pyDeprecate but imports as deprecate.

Use:

from deprecate import deprecated

print(deprecated.__name__)
Output: deprecated.__name__
deprecated

Not:

# phmdoctest:skip — intentional wrong import; would raise ModuleNotFoundError
from pydeprecate import deprecated  # wrong — no such module

UserWarning when decorating a class🔗

Q: I applied @deprecated directly to a class and got UserWarning: `@deprecated` on class `MyClass` now dispatches to `@deprecated_class`. Is this a problem, and how do I stop it?

A: No — since v0.12, @deprecated on a class is first-class supported and permanent. It dispatches to @deprecated_class() under the hood and produces an identical _DeprecatedProxy; nothing is broken and nothing needs migrating. The UserWarning is a one-time informational notice — it fires at most once per class (keyed by module + qualified name, not bare name — same-named classes in different modules each warn), not on every decoration — telling you that the dispatch happened. Only this notice — not the dispatch support itself — is removed entirely, in v1.0. The notice cache is thread-safe and retains at most 4096 class keys in insertion order; an evicted class may receive the notice again.

You have two ways to quiet it, and one alternative pattern for a different use case:

  • Apply @deprecated_class() directly — same result, and it skips the notice entirely. Prefer this for new code, and it is required to reach class-only knobs such as attrs_mapping.
  • Pass stream=None — suppresses the notice (and every other deprecation notice from that decoration) while keeping @deprecated on the class.
  • Decorate __init__ instead of the class — use this when you want a notice only at instantiation time while keeping the class name and identity untouched (no proxy involved).
from deprecate import TargetMode, deprecated_class
from enum import Enum


# Preferred: use @deprecated_class directly for classes — identical result, no notice
@deprecated_class(target=TargetMode.NOTIFY, deprecated_in="1.0", remove_in="2.0")
class MyClass:
    pass


@deprecated_class(target=TargetMode.NOTIFY, deprecated_in="1.0", remove_in="2.0")
class MyEnum(Enum):
    A = 1
    B = 2


# Alternative: decorate __init__ to warn at instantiation while keeping the class name
from deprecate import TargetMode, deprecated


class MyOtherClass:
    @deprecated(target=TargetMode.NOTIFY, deprecated_in="1.0", remove_in="2.0")
    def __init__(self, val: int) -> None:
        self.val = val  # body still executes; warning fires on every new MyOtherClass(...)


print(MyOtherClass(5).val)
Output: MyOtherClass(5).val
5

Upgrading from @deprecated on a class to @deprecated_class🔗

Q: My codebase used an older version of pyDeprecate that applied @deprecated directly to a class. It behaved strangely — isinstance() checks failed, subclassing broke, and class attributes were inaccessible. What went wrong, and how do I migrate?

A: Before v0.6.0, applying @deprecated directly to a class replaced the class object with a plain wrapper function. Python's isinstance, issubclass, and attribute lookup all operate on the class type — so replacing the class with a function silently broke every downstream use that depended on the class being a type.

The old pattern silently broke isinstance, issubclass, and attribute access

Before v0.6.0, @deprecated on a class replaced the class with a plain function. All type checks, subclassing, and class attribute access failed silently or raised TypeError. If you see this pattern in your codebase, migrate to @deprecated_class() immediately.

Symptoms of the old behaviour:

# phmdoctest:skip — shows pre-v0.6 behaviour; current code uses a proxy and no longer produces these errors
# --- Old broken pattern (pre-v0.6) ---
from deprecate import deprecated


class Renderer:
    pass


@deprecated(target=Renderer, deprecated_in="1.0", remove_in="2.0")
class Formatter:
    class_attr = 42


obj = Formatter()
isinstance(obj, Formatter)  # TypeError or False — Formatter is now a function
issubclass(Formatter, object)  # TypeError — same reason
Formatter.class_attr  # AttributeError — wrapper function has no class attributes

The replacement is @deprecated_class(), which wraps the class in a _DeprecatedProxy. The proxy forwards all attribute access, item access, calls, and type checks to the target class — so isinstance and issubclass work correctly, class attributes remain accessible, and existing subclasses continue to work.

from deprecate import deprecated_class


class MathCalculator:
    def add(self, a: int, b: int) -> int:
        return a + b


@deprecated_class(target=MathCalculator, deprecated_in="1.0", remove_in="2.0")
class Calculator:
    pass


obj = Calculator()
print(isinstance(obj, MathCalculator))  # True — proxy forwards isinstance checks
print(issubclass(Calculator, object))  # True — type checks pass through
print(obj.add(1, 2))  # 3 — forwarded to MathCalculator
Output: obj.add(1, 2)
True
True
3

The same rule applies to Enums and dataclasses — @deprecated_class is the correct API, not @deprecated:

from enum import Enum
from deprecate import deprecated_class


class ColorPalette(Enum):
    RED = 1
    BLUE = 2


Color = deprecated_class(target=ColorPalette, deprecated_in="1.5", remove_in="2.0")(ColorPalette)

print(Color.RED is ColorPalette.RED)  # True
print(Color(1) is ColorPalette.RED)  # True
print(Color["RED"] is ColorPalette.RED)  # True
Output: Color["RED"] is ColorPalette.RED
True
True
True

If you need to emit a deprecation notice only at instantiation time without deprecating the class name itself, decorate __init__ instead — this keeps the class object intact and isinstance/issubclass unaffected:

from deprecate import TargetMode, deprecated


class MyService:
    @deprecated(target=TargetMode.NOTIFY, deprecated_in="1.0", remove_in="2.0")
    def __init__(self, host: str) -> None:
        self.host = host  # body executes; warning fires at every MyService(...)


svc = MyService("localhost")
print(isinstance(svc, MyService))  # True
Output: isinstance(svc, MyService)
True

Why does a classmethod receiver keyword raise even when mapped to an empty string?🔗

A caller keyword passed through **kwargs that matches a classmethod target's receiver name would collide with the class being forwarded. An args_mapping value of "" keeps the original name, so the call raises TypeError before emitting a warning or spending its budget. Rename that keyword to a different target parameter or map it to None to drop it.

TypeError: Failed mapping🔗

Q: I get TypeError: Failed mapping of `my_func`, arguments not accepted by target: ['old_arg']. What does this mean?

A: Your deprecated function passes an argument that the target function does not accept. You need to either drop the argument, rename it to match the target's signature, or use TargetMode.ARGS_REMAP for in-place remapping.

The error fires at call time (for a generator or async generator function, on the first next() or async for step instead, because calling its wrapper only creates the generator) because pyDeprecate prepares the forwarded call from the deprecated source and validates those arguments against the target's signature. If one or more mapped names are still not accepted by the target, it raises TypeError: Failed mapping of `{source}`, arguments not accepted by target: [...]. When the target accepts *args, the message uses a slightly different variant, but it still indicates that the mapped arguments could not be accepted by the target.

Choose the fix that matches your situation:

Option 1 — Drop the argument (it is no longer needed by the target):

# define a target that ignores the extra arg
def new_func(new_arg: int) -> int:
    return new_arg * 2


# ---------------------------

from deprecate import deprecated


# None means skip this argument
@deprecated(target=new_func, args_mapping={"old_arg": None}, deprecated_in="1.0", remove_in="2.0")
def old_func(old_arg: int, new_arg: int) -> int:
    pass


print(isinstance(old_func(old_arg=1, new_arg=2), int))
Output: isinstance(old_func(old_arg=1, new_arg=2), int)
True

Option 2 — Rename the argument (the target uses a different parameter name):

def new_func(new_name: int) -> int:
    return new_name * 2


# ---------------------------

from deprecate import deprecated


# Map old to new
@deprecated(target=new_func, args_mapping={"old_name": "new_name"}, deprecated_in="1.0", remove_in="2.0")
def old_func(old_name: int) -> int:
    pass


print(isinstance(old_func(old_name=3), int))
Output: isinstance(old_func(old_name=3), int)
True

Option 3 — Use TargetMode.ARGS_REMAP (deprecating an argument of the same function, not forwarding to a different one):

from deprecate import TargetMode, deprecated


# Deprecate within same function
@deprecated(target=TargetMode.ARGS_REMAP, args_mapping={"old_arg": "new_arg"}, deprecated_in="1.0", remove_in="2.0")
def my_func(old_arg: int = 0, new_arg: int = 0) -> int:
    return new_arg * 2


print(isinstance(my_func(old_arg=1, new_arg=2), int))
Output: isinstance(my_func(old_arg=1, new_arg=2), int)
True

TypeError: skip_if function must return bool🔗

Q: I see TypeError: User function 'skip_if' shall return bool, but got: <type>. What is wrong with my skip_if callable?

A: The callable passed to skip_if must return a bool. If it returns any other type — including a truthy int or a string — pyDeprecate raises TypeError("User function 'skip_if' shall return bool, but got: ...").

pyDeprecate enforces the return type strictly so that the conditional skip behaviour is unambiguous. The error message refers to skip_if itself, not the name of your callback. Wrap any non-bool expression in an explicit bool() call, or use a lambda that returns a literal True or False. The same strict-bool contract applies wherever skip_if is accepted: @deprecated, deprecated_callable(), deprecated_class(), and deprecated_instance().

# Minimal replacement function for examples
def get_status() -> str:
    return "Hi!"


# ---------------------------

from deprecate import deprecated_callable


# Correct: function returns bool
def should_skip() -> bool:
    return False  # replace with your condition


@deprecated_callable(target=get_status, skip_if=should_skip, deprecated_in="1.0", remove_in="2.0")
def infer():
    pass


# Also correct: use a lambda
@deprecated_callable(target=get_status, skip_if=lambda: False, deprecated_in="1.0", remove_in="2.0")
def infer_v2():
    pass


print(infer())
print(infer_v2())
Output: infer(), infer_v2()
Hi!
Hi!

Deprecation notice not appearing🔗

Q: I call my deprecated function but no deprecation notice is printed. Where did it go?

A: By default, pyDeprecate emits the deprecation message only once per function (num_warns=1) to avoid log spam. After the first call, subsequent calls are silent. Set num_warns=-1 for unlimited emissions or num_warns=N for exactly N emissions.

For per-argument deprecation (when using args_mapping with TargetMode.ARGS_REMAP), each deprecated argument has its own independent message counter — so deprecation messages for different arguments are tracked separately and each fires once by default. A message for a call that uses several deprecated arguments names only those whose counter is not yet spent, so an argument you were already warned about is not listed again.

# Minimal replacement function for examples
def score_predictions(val: int) -> int:
    return val * 2


# ---------------------------

from deprecate import deprecated


# Show warning every time
@deprecated(target=score_predictions, deprecated_in="1.0", remove_in="2.0", num_warns=-1)
def predict():
    pass


# Show warning N times total
@deprecated(target=score_predictions, deprecated_in="1.0", remove_in="2.0", num_warns=5)
def predict_batch():
    pass


print(callable(predict), callable(predict_batch))
Output: callable(predict), callable(predict_batch)
True True

If you are writing tests and need to verify that a warning fires, use pytest.warns(FutureWarning) on the first call and assert_no_warnings(FutureWarning) on subsequent calls. See Testing Deprecated Code for full examples.


Why did the deprecation warning stop appearing in my long-running service?🔗

Q: My worker logged the deprecation warning once after a deploy and has been silent for weeks, although the deprecated path is surely still called. Did the warning break?

A: No. The num_warns budget is per-process, not per-deployment. A long-running service — a worker, a web server — that hits a deprecated code path once at startup exhausts the default num_warns=1 immediately; the path stays completely silent for the rest of that process's life, even after weeks of steady-state traffic. Checking production logs and seeing nothing does not mean the deprecated path is unused — it may mean the one allowed warning already fired and scrolled out of the retained log window. To confirm whether a deprecated path is still being hit in a running service: restart the process (the counter resets) and watch for the warning again, or redeploy temporarily with num_warns=-1 to invoke the configured warning path on every call. Standard warning filters can still suppress repeated display; a custom stream can record each delivery.

Deprecation target path incorrect across modules🔗

Q: I moved a function to a different module and the deprecation message shows an unexpected path. How do I fix the displayed module path?

A: The deprecation message reports the fully-qualified path of the target callable as Python resolves it at decoration time. Ensure the target is imported from its canonical location before the @deprecated decorator is applied.

When moving functions across modules, import the target from its new home explicitly rather than relying on a re-export alias. The path shown in the deprecation message will then reflect the module where the function actually lives, giving callers accurate migration information. The message will correctly show the full path for real imports when used in your package.

Do arithmetic and operator protocol methods emit deprecation notices on deprecated_instance proxies?🔗

Q: I wrapped an int or float with deprecated_instance and operations like old_value + 1 or abs(old_value) don't seem to emit a notice. How does operator interception work?

A: Yes, the proxy forwards operator and protocol dunders to the wrapped object and emits the deprecation warning according to a consistent policy. Python's data model invokes special methods directly on the object's type (bypassing __getattr__), so these must be defined on the proxy's class itself — and they are. Each proxy is an instance of a cached _DeprecatedProxy subclass that carries only the protocols the wrapped object's type defines, so a proxy over a dict is not callable() and not an Iterator, exactly like the dict. Text is the one exception: int(), float(), complex() and os.fspath() keep working on a proxy over a str or bytes value.

What warns (data-use operations):

  • Arithmetic operators (+, -, *, /, //, **, %, @), divmod(), and their reflected/in-place forms
  • Bitwise and shift operators (&, |, ^, <<, >>) and their reflected/in-place forms
  • Numeric/path conversions: int(), float(), complex(), operator.index(), os.fspath()
  • Unary numeric operators: abs(), -obj, +obj, ~obj
  • Rounding: round(), math.trunc(), math.floor(), math.ceil()
  • reversed(), next()
  • Context-manager entry (with proxy:, async with proxy:)
  • Async iteration entry (async for)
  • Awaiting a proxy (await proxy)
  • Subclassing (class Child(proxy):)

What stays silent (structural probes):

  • Ordering comparisons: <, <=, >, >=
  • bool() and hash()
  • The in operator, when the proxy's own __contains__ answers it (a wrapped type without __contains__ makes Python fall back to the proxy's warning __iter__ / __getitem__, so in then warns like iteration)
  • format() / f-strings using the wrapped object
  • Context-manager exit (__exit__ / __aexit__)

This mirrors the existing silent/warn split for __eq__ / __len__ / __str__ versus __getitem__ / __iter__.

In-place operator rebinding: In-place operators (+=, -=, etc.) return the active object's own result, not a re-wrapped proxy. Assigning the result rebinds the variable to the underlying type:

from deprecate import deprecated_instance

n = deprecated_instance(3, deprecated_in="1.0", remove_in="2.0", stream=None)
n += 1  # silent (stream=None); n is now plain int(4), not a proxy
print(type(n).__name__)
Output: type(n).__name__
int

One known edge case — strict-type reflected operators: When the proxy is on the right-hand side and the left operand's type raises TypeError directly (instead of returning NotImplemented), Python cannot fall back to the proxy's reflected method. For example, "prefix" + proxy_str fails because str.__add__ raises TypeError for non-string operands rather than returning NotImplemented. The proxy-on-left form works normally and warns:

from deprecate import deprecated_instance

s = deprecated_instance("hello", deprecated_in="1.0", remove_in="2.0", stream=None)
print(s + " world")  # silent (stream=None) — proxy on left side works
# " world" + s → TypeError (str.__add__ raises instead of returning NotImplemented)
Output: s + " world"
hello world

For primitive constants where reflected operations on the right are required, wrap the value in a container (dict or dataclass) so access goes through __getitem__ or __getattr__:

from deprecate import deprecated_instance

# Container approach — subscript access warns regardless of operand position
_THRESHOLDS = {"value": 0.5}
OLD_THRESHOLD = deprecated_instance(
    _THRESHOLDS,
    name="OLD_THRESHOLD",
    deprecated_in="1.0",
    remove_in="2.0",
)

# Access via subscript triggers the warning:
print(OLD_THRESHOLD["value"])
Output: OLD_THRESHOLD["value"]
0.5

Can I subclass a class created by deprecated_class?🔗

Q: I tried class Child(OldName): pass where OldName was created by deprecated_class(target=NewBase, ...). In older pyDeprecate versions this raised TypeError. Does subclassing work now?

A: Yes. The proxy implements PEP 560 __mro_entries__ so Python transparently subclasses the replacement class instead of the proxy. Subclassing is treated as usage of the deprecated name and emits one FutureWarning — the class hierarchy is correct at the Python level.

from deprecate import deprecated_class


class Base:
    def greet(self) -> str:
        return "hello"


OldBase = deprecated_class(target=Base, deprecated_in="1.0", remove_in="2.0")(Base)


class Child(OldBase):  # warns: FutureWarning
    pass


print(issubclass(Child, Base))
print(Child().greet())
Output: issubclass(Child, Base); Child().greet()
True
hello

One warning fires at class-definition time (when class Child(OldBase) is evaluated), against the shared num_warns budget of OldBase. Exception: attrs_mapping-only proxies (TargetMode.ATTRS_REMAP) and args_mapping-only proxies (TargetMode.ARGS_REMAP) do not warn on subclassing — both modes scope deprecation to specific axes (attribute names or argument names respectively), not general usage of the class alias.

How do I redirect deprecation output to a logger instead of warnings.warn?🔗

Q: I want deprecation messages to go through Python's logging module instead of the default warnings.warn mechanism. How?

A: Pass any logging method as the stream parameter. The stream callable receives the formatted deprecation message as a single string argument — logging methods like logging.warning have exactly this signature. For the full range of stream options (silencing, custom callables, print), see Deprecation Output Sink.

import logging
from deprecate import deprecated

# Configure logging (typically done once at application startup)
logging.basicConfig(
    level=logging.WARNING,
    format="%(asctime)s [%(levelname)s] %(message)s",
)


def new_endpoint(url: str) -> str:
    return f"GET {url}"


@deprecated(
    target=new_endpoint,
    deprecated_in="2.0",
    remove_in="3.0",
    stream=logging.warning,
)
def old_endpoint(url: str) -> str:
    pass


# Instead of a FutureWarning, emits a log line:
#   2026-04-20 12:00:00 [WARNING] The `old_endpoint` was deprecated since v2.0
#   in favor of `your_module.new_endpoint`. It will be removed in v3.0.
print(old_endpoint("/api/users"))
Output: old_endpoint("/api/users")
GET /api/users

Choosing the log level:

Level When to use
logging.info Early deprecation window; low urgency
logging.warning Standard choice; default log configs show it
logging.error Critical deprecation nearing removal deadline

Benefits over warnings.warn:

  • Integrates with your existing log aggregation (ELK, Datadog, CloudWatch, etc.)
  • Respects your log format, handlers, and filters
  • Always emitted regardless of Python's warning filter state (no -W flag interaction)
  • Timestamps included automatically if your formatter adds them

Note: When using stream=logging.warning, the num_warns parameter still controls how many times the message is emitted. The combination of num_warns=-1 with stream=logging.warning ensures every deprecated call site is logged — useful for measuring migration progress via log analytics.


Why doesn't deprecated_class warn when I call it with the new argument name?🔗

Q: I set up deprecated_class(args_mapping={"old_arg": "new_arg"}, ...) on my class but no warning fires when I call it with new_arg=.... Did I configure it incorrectly?

A: No — this is the intended behaviour. When args_mapping is provided with target omitted, the proxy auto-resolves to TargetMode.ARGS_REMAP and warns only when the old argument name is actually present in the call. Callers who have already migrated to the new argument name see no warning. This matches the per-argument warning behaviour of @deprecated(target=TargetMode.ARGS_REMAP, args_mapping=...).

from deprecate import deprecated_class


class Config:
    def __init__(self, timeout: int = 0) -> None:
        self.timeout = timeout


# args_mapping without a callable target → auto ARGS_REMAP
LegacyConfig = deprecated_class(
    args_mapping={"time_limit": "timeout"},
    deprecated_in="1.5",
    remove_in="2.0",
)(Config)

print(LegacyConfig(timeout=30).timeout)  # new name — no warning (caller already migrated)
print(LegacyConfig(time_limit=30).timeout)  # old name — FutureWarning emitted + remapped
Output: LegacyConfig(...).timeout
30
30

Auto-resolve applies only when target is omitted. Passing target=TargetMode.NOTIFY explicitly together with args_mapping is a misconfiguration: a UserWarning fires at decoration time (TypeError in v1.0), the mode stays NOTIFY, and the mapping is inert at runtime (preserved in audit metadata, flagged misconfigured) — explicit configuration is never silently rewritten. With target omitted, the proxy auto-resolves a present args_mapping to TargetMode.ARGS_REMAP (on the @deprecated front door this is the TargetMode.AUTO default doing the inference). There is no single-proxy way to get a blanket per-instantiation warning while args_mapping is also active. If you need both — every instantiation warns and an old argument name is remapped — stack two deprecated_class() layers: an inner layer with args_mapping only (ARGS_REMAP) for the rename, and an outer layer with no mapping (NOTIFY) for the blanket notice. See Nested proxy wrappers for the pattern.


I passed both the old and new argument names at the same time — which value wins?🔗

Q: My function is decorated with @deprecated(args_mapping={"val": "new_val"}, ...). A caller accidentally passes both val=5 and new_val=6 in the same call. Which value does the target receive for new_val?

A: The explicit new-name value always wins. When both the deprecated old name and the canonical new name are present in the same call, pyDeprecate snapshots the new-name value before remapping, then restores it afterwards. new_val=6 is what the target (or source body under TargetMode.ARGS_REMAP) receives — the remapped val→new_val=5 is discarded. This holds regardless of the order the caller listed the arguments.

In addition to the normal FutureWarning about the deprecated argument, pyDeprecate emits a UserWarning naming the ignored old argument so the caller can clean up the call site:

UserWarning: Both `val` (deprecated) and `new_val` were supplied to `old_api()`; `val` is ignored.
from deprecate import TargetMode, deprecated


def new_api(new_val: int = 0) -> int:
    return new_val


@deprecated(target=new_api, args_mapping={"val": "new_val"}, deprecated_in="1.0", remove_in="2.0")
def old_api(val: int = 0, new_val: int = 0) -> int:
    return new_val


print(old_api(val=5, new_val=6))  # warns: FutureWarning (UserWarning also fires: val ignored)
print(old_api(new_val=6, val=5))  # warns: FutureWarning (UserWarning also fires: same result)
Output: old_api(...)
6
6

Precedence hierarchy (highest to lowest):

  1. args_extra values — merged last; unconditionally overwrite all caller-supplied values including the explicit new-name kwarg.
  2. Explicit new-name kwarg — wins over the remapped old-name value.
  3. Remapped old-name value — wins over source defaults.
from deprecate import deprecated


def new_api(new_val: int = 0) -> int:
    return new_val


@deprecated(
    target=new_api,
    args_mapping={"val": "new_val"},
    args_extra={"new_val": 99},
    deprecated_in="1.0",
    remove_in="2.0",
)
def old_api_with_extra(val: int = 0, new_val: int = 0) -> int:
    return new_val


print(old_api_with_extra(val=5, new_val=6))  # args_extra wins — result is 99, not 6
Output: old_api_with_extra(val=5, new_val=6)
99

This applies equally to @deprecated with target=TargetMode.ARGS_REMAP or a callable target, and to deprecated_class() / deprecated_instance() with args_mapping.


UserWarning when using TargetMode.ARGS_REMAP without args_mapping🔗

Q: I applied @deprecated(target=TargetMode.ARGS_REMAP, ...) and got the warning UserWarning: @deprecated(target=TargetMode.ARGS_REMAP) on my_func requires args_mapping .... Why, and how do I fix it?

A: TargetMode.ARGS_REMAP is designed exclusively for renaming or dropping arguments within the same function. Without args_mapping there is nothing to remap — the decorator has zero call-time effect. This is a misconfiguration that emits a UserWarning today and will become a TypeError in v1.0.

Choose the mode that matches your intent:

  • Rename or drop a parameter — provide args_mapping as required:
from deprecate import TargetMode, deprecated


@deprecated(target=TargetMode.ARGS_REMAP, args_mapping={"old_name": "new_name"}, deprecated_in="1.0", remove_in="2.0")
def my_func(old_name: int = 0, new_name: int = 0) -> int:
    return new_name * 2


print(isinstance(my_func(old_name=3), int))
Output: isinstance(my_func(old_name=3), int)
True
  • Warn callers with no forwarding or remapping — use TargetMode.NOTIFY instead:
from deprecate import TargetMode, deprecated


@deprecated(target=TargetMode.NOTIFY, deprecated_in="1.0", remove_in="2.0")
def my_func(val: int) -> int:
    """Going away — remove all call sites."""
    return val * 2


print(my_func(3))
Output: print(my_func(3))
6

This misconfiguration will become a TypeError in v1.0

Migrate now to avoid a hard break on upgrade. Use the audit tools to detect this combination across your codebase automatically — find_deprecation_wrappers() reports it via the misconfigured_target flag on DeprecationWrapperInfo.


attrs_mapping auto-expand skips some fields on a dataclass with a custom __init__🔗

Q: I added attrs_mapping to a deprecated_class() wrapping a dataclass that overrides __init__. Some deprecated aliases aren't being auto-expanded into args_mapping — constructor calls with the old name raise TypeError instead of remapping. Why?

A: When deprecated_class() detects a @dataclass target, it automatically copies attrs_mapping entries into args_mapping so that both attribute access (proxy.old_field) and constructor calls (DC(old_field=5)) emit FutureWarning. The auto-expand consults inspect.signature to determine which names are valid __init__ kwargs.

There are two cases where a field is intentionally excluded:

  1. field(init=False) — the field is an instance attribute set inside __init__ (or __post_init__) but is not a constructor parameter. Passing it as a kwarg would raise TypeError.

  2. Custom __init__ that omits a dataclass field — if the overridden __init__ intentionally leaves a field out of its signature, that field is not a valid kwarg and is excluded from auto-expand.

Both surfaces still work independently: attrs_mapping redirects attribute access for all listed aliases (including init=False fields), while args_mapping only covers fields present in the actual constructor signature.

from dataclasses import dataclass, field
from deprecate import deprecated_class, get_deprecation_config


@dataclass
class Config:
    timeout: int = 30
    _cache: dict = field(default_factory=dict, init=False)  # not a constructor param


DepConfig = deprecated_class(
    attrs_mapping={"time_limit": "timeout", "store": "_cache"},
    deprecated_in="1.0",
    remove_in="2.0",
    stream=None,
)(Config)

meta = get_deprecation_config(DepConfig)
print("timeout auto-expanded:", "time_limit" in meta.args_mapping_auto_expanded)
print("_cache auto-expanded:", "store" in meta.args_mapping_auto_expanded)
Output: meta.args_mapping_auto_expanded
timeout auto-expanded: True
_cache auto-expanded: False

store is excluded from args_mapping — calling DepConfig(store={}) would raise TypeError. Use attrs_mapping for attribute access and leave the init=False field out of constructor calls.


AttributeError: 'str' object has no attribute 'target' after upgrading to v0.13🔗

Q: Code that used to read wrapper.__deprecated__.target (or .deprecated_in, or checked isinstance(wrapper.__deprecated__, DeprecationConfig)) now raises AttributeError: 'str' object has no attribute .... What changed?

A: Since v0.13, __deprecated__ holds a plain PEP 702-conformant message string — the same convention the standard library's warnings.deprecated / typing_extensions.deprecated use — instead of the internal DeprecationConfig object. This fixes a collision: before v0.13, PEP-702-aware tooling (IDE hover, pyright, mypy) reading pyDeprecate's __deprecated__ got a DeprecationConfig repr where a message string belongs.

The config object moved to __deprecation_config__. Read it through the public get_deprecation_config(obj) helper rather than either attribute name directly — it also transparently falls back to a legacy __deprecated__-as-config layout for objects built by a pre-v0.13 pyDeprecate release, so code migrated this way keeps working across mixed-version installs.

from deprecate import deprecated, get_deprecation_config


def new_func(x: int) -> int:
    return x * 2


@deprecated(target=new_func, deprecated_in="1.0", remove_in="2.0")
def old_func(x: int) -> int:  # warns: nothing — decoration only, no call yet
    pass


print(isinstance(old_func.__deprecated__, str))
print(get_deprecation_config(old_func).target is new_func)
Output: isinstance(...) and get_deprecation_config(...).target
True
True

Affects every wrapper kind — functions, methods, class proxies (deprecated_class), instance proxies (deprecated_instance), and modules (deprecated_module). See the v0.12-to-v0.13 migration guide for the full list of affected reads.


My object mutated despite read_only=True🔗

Q: I passed read_only=True to deprecated_instance() but a method on my object still mutated its state. Why?

A: read_only=True intercepts only the following standard collection mutator names: append, clear, discard, extend, insert, pop, remove, setdefault, update, add. These cover the mutating methods on Python's built-in list, dict, and set types.

Custom method names — for example register(), reload(), or set_value() — are not in this list and call through to the underlying object without any guard.

The guard is also off while skip_if evaluates True: attribute writes, item writes and deletes (proxy[key] = value, del proxy[key]), and mutator calls then reach the wrapped object directly, because an active skip_if switches the whole deprecation machinery off.

Workaround: Subclass the wrapped object's type and override the custom mutator method to raise explicitly:

class ReadOnlyRegistry(dict):
    def register(self, item):
        raise AttributeError("'LEGACY_REGISTRY' is deprecated and read-only. Migrate away from this object.")


print(issubclass(ReadOnlyRegistry, dict))
Output: issubclass(ReadOnlyRegistry, dict)
True

Then wrap an instance of ReadOnlyRegistry instead of a plain dict. This keeps read_only=True in place for standard collection mutators while adding explicit guards for your custom methods.


TypeError at decoration time: cross-class method target🔗

Q: I got the following error at decoration time — what does it mean and how do I fix it?

TypeError: Cannot use @deprecated on 'Foo.old_method' with target 'Bar.new_method':
cross-class method forwarding is not supported because `self` would carry the wrong type.
The target must be a method on the same class ('Foo') or a full class (use target=Bar for class migration).

A: The cross-class guard in pyDeprecate raises TypeError at decoration time (when the class body is executed), not at call time. It fires when @deprecated on a method in class Foo points to a method defined on a different class Bar. Forwarding to a method on a different class silently passes self of the wrong type, causing AttributeError or incorrect behaviour at runtime — the guard prevents this misconfiguration from reaching production.

Common fix — forward to the correct target within the same class or to a standalone function:

from deprecate import deprecated


class MyService:
    def execute(self, val: int) -> int:
        return val * 2

    # Correct: target is on the same class
    @deprecated(target=execute, deprecated_in="1.0", remove_in="2.0")
    def run(self, val: int) -> int:
        pass


print(MyService().run(3))
Output: MyService().run(3)
6

If you are intentionally delegating to another class, convert the target to a standalone function or use @deprecated_class to deprecate the whole class instead.

Exception — @staticmethod source: The guard is skipped when the deprecated source is itself a @staticmethod. A staticmethod receives no self, so cross-class forwarding is safe. Use the decorator order @deprecated(...) @staticmethod (or equivalently @staticmethod @deprecated(...)):

from deprecate import deprecated


class NewMath:
    @staticmethod
    def compute(x: int) -> int:
        return x * 3


class OldMath:
    @deprecated(target=NewMath.compute, deprecated_in="1.0", remove_in="2.0")
    @staticmethod
    def compute(x: int) -> int:
        pass


print(OldMath.compute(5))  # warns: FutureWarning — forwards to NewMath.compute
Output: OldMath.compute(5)
15

False-positive triggers fixed in v0.8:

Before v0.8, two patterns produced spurious TypeError raises from the guard:

  • Decorators that rewrite __qualname__ — a decorator applied before @deprecated that sets fn.__qualname__ = "OtherClass.method" caused the guard to see the wrong owner class. Fixed in v0.8 by reading __qualname__ from the enclosing class-body frame, which Python sets before any decorator runs.
  • Metaclass-generated classes — type("Name", bases, ns) and similar patterns produce qualnames like "FakeOwner.method" for methods that are not actually on FakeOwner. Fixed in v0.8 by verifying that the class name in the qualname prefix actually exists in the module globals; when it does not, the guard skips the check.

If you are on v0.8+ and still seeing an unexpected TypeError from the cross-class guard, open an issue with a minimal reproducer.


UserWarning at decoration time: unsupported stacking combination🔗

Q: I got a UserWarning when applying multiple @deprecated decorators to the same function. What does it mean and how do I fix it?

The message looks like one of:

UserWarning: 'my_func' has @deprecated(NOTIFY) stacked over @deprecated(ARGS_REMAP).
Reverse the decorator order: put @deprecated(ARGS_REMAP, ...) outermost (on top)
and @deprecated(NOTIFY, ...) below it. Will be `TypeError` in `v1.0`.

A: pyDeprecate validates stacking combinations at decoration time and emits UserWarning for every unsupported case. Common supported combinations include:

  • ARGS_REMAP (outer, on top) + ARGS_REMAP (inner): multi-step argument renames across versions.
  • ARGS_REMAP (outer, on top) + NOTIFY (inner): lifecycle pattern — rename args first, then deprecate the whole function.
  • NOTIFY (outer, on top) + callable (inner): deprecate a callable target directly without an inner @deprecated wrapper.

See Supported stacking combinations for the full table. The warning message identifies which combination fired and includes a corrective hint.

Most common case — wrong order (NOTIFY over ARGS_REMAP):

from deprecate import TargetMode, deprecated


# Wrong — NOTIFY outer emits UserWarning at decoration time
@deprecated(deprecated_in="2.0", remove_in="3.0")  # outer NOTIFY
@deprecated(TargetMode.ARGS_REMAP, deprecated_in="1.0", remove_in="2.0", args_mapping={"old": "new"})
def my_func(old: int = 0, new: int = 0) -> int:
    return new


# Correct — ARGS_REMAP on top, NOTIFY below
@deprecated(TargetMode.ARGS_REMAP, deprecated_in="1.0", remove_in="2.0", args_mapping={"old": "new"})
@deprecated(deprecated_in="2.0", remove_in="3.0")  # inner NOTIFY
def my_func(old: int = 0, new: int = 0) -> int:
    return new


print(my_func())
Output: my_func()
0

My deprecated generator does not warn until I iterate it🔗

Q: My generator function is decorated with @deprecated. Calling the function does not warn — the deprecation warning only appears when I call next() or start iterating. Is this a bug?

A: No — this is the intended behavior. The wrapper of a generator function is itself a generator function, so calling it only creates the generator object and runs no code; the warning, the num_warns budget and an as_exceptions() error all come on the first step, and a generator that is never iterated never warns.

Keeping the wrapper a generator function keeps inspect.isgeneratorfunction(wrapper) True, which frameworks depend on: pytest, for example, runs a deprecated yield fixture with its setup and teardown only because of that flag. The warning points at the line that takes the first item. To see it in a test, put pytest.warns() around the iteration, not around the call.

from deprecate import deprecated, void


def generate_ids(start: int, count: int):
    for i in range(count):
        yield start + i


@deprecated(target=generate_ids, deprecated_in="0.9", remove_in="1.0")
def iter_ids(start: int, count: int):
    void(start, count)
    yield from ()  # `yield` keeps the stub a generator function


# No warning yet — creating the generator runs no code
gen = iter_ids(10, 3)

# Warning fires here — on the first item
print(list(gen))  # warns: FutureWarning
Output: list(gen)
[10, 11, 12]

My deprecated async generator does not warn until I iterate it🔗

Q: My async generator function is decorated with @deprecated. Calling the function does not warn — the deprecation warning only appears once my async for loop starts. Is this a bug?

A: No — this is the intended behavior. The wrapper of an async generator function is itself an async generator function, so calling it only creates the async generator object; the warning fires on the first async for step (the first __anext__()), and an async generator that is never iterated never warns.

import asyncio
from deprecate import deprecated


async def stream(n: int):
    for i in range(n):
        yield i


@deprecated(target=stream, deprecated_in="0.9", remove_in="1.0")
async def old_stream(n: int):
    if False:  # pragma: no cover
        yield 0


# No warning yet — creating the async generator runs no code
agen = old_stream(3)


async def consume(gen):
    async for _ in gen:
        pass


# Warning fires here — on the first `async for` step
asyncio.run(consume(agen))  # warns: FutureWarning

Note: Because the wrapper is an async generator function, inspect.isasyncgenfunction(old_stream) returns True, so frameworks that check this flag (pytest-asyncio fixtures, for one) treat it exactly like the undecorated function.


My deprecated wrapper's warning fires when I define a function, not when I call it🔗

Q: I have a deprecated function that is meant to be used as a decorator (@deprecated(target=new_wrapper, ...) applied to a wrapper-producing function). When I apply it with @ syntax to one of my own functions, the deprecation warning fires immediately — at the def statement — before my function is ever called. Is this a bug in decorator ordering?

A: No — this is by design, not a timing bug. @my_deprecated_wrapper on def my_func(): ... is exactly equivalent to my_func = my_deprecated_wrapper(my_func): decorating a function is calling the deprecated wrapper, immediately, at the point the def statement executes (definition time — which is also import time, if the def sits at module scope). The deprecation warning fires there because that is when the deprecated callable actually runs; it has nothing to do with my_func being called later.

from deprecate import deprecated


def new_wrapper(func):
    return func


@deprecated(target=new_wrapper, deprecated_in="0.9", remove_in="1.0")
def old_wrapper(func):
    return func


# Warning fires HERE, at the @old_wrapper line — decorating IS calling old_wrapper(sample)
@old_wrapper
def sample(x: int) -> int:
    return x * 2


# FutureWarning: The `old_wrapper` was deprecated since v0.9 in favor of `new_wrapper`.

# Calling sample(...) later does not warn again — the deprecated call already happened above
print(sample(5))
Output: sample(5)
10

This is consistent with every other pyDeprecate warning: the message fires when the deprecated callable is invoked, never deferred to some later "use" of its return value. There is no option to defer the warning until sample(...) runs instead — that would require wrapping old_wrapper's return value in a second layer of interception, which brings its own num_warns/introspection complications for no clear benefit. If you need the warning to appear at the call site of the wrapped function instead of at definition time, decorate the target explicitly at the call site rather than via @ syntax on the definition, or apply @deprecated directly to sample instead of to the wrapper that produces it.


Warning fires or UserWarning appears when using @deprecated @classmethod🔗

Q: I applied @deprecated on top of @classmethod (decorator order: @deprecated outermost, @classmethod innermost). Does this work?

A: Yes — pyDeprecate silently rescues the misordered stack at decoration time. When you write:

from deprecate import deprecated


class Foo:
    # Works — @deprecated outside @classmethod is transparently rescued
    @deprecated(deprecated_in="1.0", remove_in="2.0")
    @classmethod
    def old_method(cls, val): ...


print(Foo.old_method(1))
Output: Foo.old_method(1)
None

@deprecated detects that it received a classmethod descriptor, unwraps it, applies the deprecation wrapper to the underlying function, and re-wraps the result in classmethod. The outcome is a fully working deprecated classmethod: no UserWarning at decoration time, and a FutureWarning fires when the method is called.

The preferred order is still @classmethod outermost with @deprecated applied closer to def — it is explicit and avoids the silent rescue:

from deprecate import deprecated


def new_impl(cls, val):
    return val * 2


class Foo:
    # Preferred — @deprecated applied to the raw function, @classmethod wraps the result
    @classmethod
    @deprecated(target=new_impl, deprecated_in="1.0", remove_in="2.0")
    def old_method(cls, val):
        pass


print(Foo.old_method(1))
Output: Foo.old_method(1)
2

The same rule applies to @staticmethod.


Concurrent async calls and warning counts🔗

Q: I have multiple coroutines all calling the same deprecated async def wrapper concurrently. The deprecation notice only appeared once, but I expected it to fire num_warns times. What happened?

A: Concurrent async calls sharing one wrapper cannot race while reserving warning budgets. The async wrapper reaches warning dispatch before its first await, and the callable or per-argument budget check and reservation use the wrapper's lock. Each deprecated argument has its own num_warns budget, so that value is not one overall cap when calls use several deprecated arguments.

The budget controls warning attempts, not an exact number of visible warning records. Standard warning filters may suppress records, and a custom warning stream may raise after a callable wrapper reserves its budget; callable warning delivery does not restore that reservation. Proxy delivery has separate rollback behavior. Set num_warns=-1 only when unlimited warning attempts are intended, not to avoid a concurrency race.


My deprecated async def warning does not appear in CI🔗

Q: I decorated an async def function with @deprecated but the deprecation notice never shows up in my CI logs or test output. Why, and how do I fix it?

A: For async def functions, the deprecation warning fires when the coroutine is awaited, not when the wrapper is called. Creating the coroutine object — coro = old_fn(val=1) — produces no warning; await coro is what triggers it.

This means warnings can be silently lost in these common scenarios:

  • asyncio.create_task(old_fn(val=1)) or tasks scheduled via fixtures or library hooks — the warning fires when the event loop executes the coroutine, which may happen outside any active catch_warnings context (e.g., in application startup code, background workers, or test fixtures that schedule work on the event loop).
  • Third-party schedulers (Celery, arq, anyio task groups) — the coroutine is handed off and the warning fires in the scheduler's execution context, not the caller's, and may be routed to a different warning filter.
  • pytest.warns(FutureWarning) wrapping only the call — with pytest.warns(FutureWarning): coro = old_fn() captures nothing because no warning fires at call time. The block must wrap the await.
import asyncio
import warnings
from deprecate import deprecated


async def new_fetch(url: str) -> bytes:
    return url.encode()


@deprecated(target=new_fetch, deprecated_in="0.9", remove_in="1.0")
async def old_fetch(url: str) -> bytes:
    pass


async def captured_correctly():
    result = await old_fetch("https://example.com")  # warns: FutureWarning
    return result


asyncio.run(captured_correctly())

Fix 1 — use stream=logging.warning (recommended for CI):

Logging bypasses Python's warning filter state entirely. The deprecation message appears in your log output regardless of when or where the coroutine is awaited.

import logging
from deprecate import deprecated


async def new_fetch(url: str) -> bytes:
    return url.encode()


@deprecated(target=new_fetch, deprecated_in="0.9", remove_in="1.0", stream=logging.warning)
async def old_fetch(url: str) -> bytes:
    pass

Fix 2 — wrap the await in tests, not just the call:

import pytest


@pytest.mark.asyncio
async def test_old_fetch_warns():
    with pytest.warns(FutureWarning):
        await old_fetch("https://example.com")  # correct: await inside the warns block

Async warning timing is by design

The async def wrapper is a true coroutine (inspect.iscoroutinefunction(wrapper) returns True), so the deprecation warning fires inside the coroutine body when execution begins — on await. Warnings for generators and async generators wait in the same way, for the first next() / async for step, because their wrappers are generator and async generator functions; only plain functions warn at call time.


Why can't I decorate a functools.partial or callable wrapper?🔗

Q: I applied @deprecated to a functools.partial, functools.partialmethod, or singledispatchmethod. Which callable forms can be decorated?

A: @deprecated accepts Python functions, supported descriptors, and classes as sources. Callable wrappers such as functools.partial, functools.partialmethod, and singledispatchmethod are rejected with TypeError; they do not become supported sources merely because their wrapped callable is async. Define a plain def or async def with the desired public signature and decorate that named function. For async targets, pyDeprecate follows inspect.iscoroutinefunction on the supported Python runtime; an async callable instance that returns an awaitable is a distinct kind and may not be recognized as a coroutine function. deprecated_instance is for deprecating an object alias, not a substitute for a decorated function source.

import asyncio
import functools
from deprecate import deprecated


async def new_fetch(url: str, timeout: int = 30) -> bytes:
    return url.encode()


# Correct: apply @deprecated to the async def directly
@deprecated(target=new_fetch, deprecated_in="0.9", remove_in="1.0")
async def old_fetch(url: str, timeout: int = 30) -> bytes:
    pass


# Then use partial on the already-deprecated async wrapper if needed
fetch_with_timeout = functools.partial(old_fetch, timeout=10)
url = asyncio.run(old_fetch("https://example.com"))
print(url.decode())
Output: asyncio.run(old_fetch("https://example.com")).decode()
https://example.com

A non-async decorator applied over my @deprecated async wrapper no longer looks like a coroutine🔗

Q: I have @my_decorator @deprecated(...) async def fn(...). After decoration, inspect.iscoroutinefunction(fn) returns False and asyncio frameworks (FastAPI route handlers, asyncio.create_task) reject it. Why?

A: When you wrap a @deprecated async def with a sync decorator — one that returns a sync wrapper(*args, **kwargs) via functools.wraps — the result is a sync callable. inspect.iscoroutinefunction inspects the outermost callable's CO_COROUTINE flag, not the wrapped function, so the coroutine nature is lost the moment a sync wrapper is placed on top.

Solution: Make any outer decorator that may be applied over an async wrapper coroutine-aware. Inspect the wrapped callable and emit either an async def or a sync wrapper accordingly.

import functools
import inspect


def my_decorator(fn):
    if inspect.iscoroutinefunction(fn):

        @functools.wraps(fn)
        async def async_wrapper(*args, **kwargs):
            return await fn(*args, **kwargs)

        return async_wrapper

    @functools.wraps(fn)
    def sync_wrapper(*args, **kwargs):
        return fn(*args, **kwargs)

    return sync_wrapper


print(my_decorator(lambda x: x + 1)(41))
Output: my_decorator(lambda x: x + 1)(41)
42

With this pattern, stacking @my_decorator above @deprecated on an async def produces an async def wrapper whose inspect.iscoroutinefunction(...) returns True.


Setter or deleter on a deprecated property doesn't warn🔗

Q: I decorated my property with @deprecated and added a setter via @value.setter, but writing to the property is silent — no FutureWarning is emitted. Deleting via del obj.value is also silent. What is happening?

A: The two property decorator orders behave differently. Inner order (@property outermost, @deprecated closer to def) wraps the getter (fget) only. Any setter or deleter added later via @value.setter / @value.deleter is a plain accessor with no deprecation closure, so writes and deletes are silent.

# phmdoctest:skip — silent setter/deleter is intended for inner-order wrapping
from deprecate import deprecated


class MyClass:
    @property
    @deprecated(deprecated_in="1.0", remove_in="2.0")  # inner order — wraps fget only
    def value(self) -> int:
        return self._value

    @value.setter
    def value(self, new_value: int) -> None:
        self._value = new_value  # write is SILENT — no FutureWarning

    @value.deleter
    def value(self) -> None:
        self._value = 0  # delete is SILENT — no FutureWarning

Use outer order (@deprecated outermost, @property closer to def) so all three accessors are wrapped — the property subclass re-wraps any setter or deleter added later via chain-style decorators:

from deprecate import deprecated


class MyClass:
    def __init__(self) -> None:
        self._value = 0

    @deprecated(deprecated_in="1.0", remove_in="2.0")  # outer order — wraps fget, fset, fdel
    @property
    def value(self) -> int:
        return self._value

    @value.setter
    def value(self, new_value: int) -> None:
        self._value = new_value  # write now FIRES FutureWarning

    @value.deleter
    def value(self) -> None:
        self._value = 0  # delete now FIRES FutureWarning


obj = MyClass()
obj.value = 5  # FutureWarning fires here
del obj.value  # FutureWarning fires here
print(obj._value)
Output: obj._value
0

When you need full control over which accessors warn — for example a setter-only or deleter-only property — construct the property explicitly and wrap it once with @deprecated:

from deprecate import deprecated


def _fget(self):
    return self._value


def _fset(self, v):
    self._value = v


def _fdel(self):
    self._value = None


class MyClass:
    def __init__(self) -> None:
        self._value = 0

    # All three accessors deprecation-wrapped in one shot.
    value = deprecated(deprecated_in="1.0", remove_in="2.0")(property(_fget, _fset, _fdel))


obj = MyClass()
obj.value = 7  # FutureWarning on write
print(obj.value)  # FutureWarning on read
del obj.value  # FutureWarning on delete
print(obj._value)
Output: obj._value
7
None

CI / audit detection: find_deprecation_wrappers flags inner-order properties with inner_order_property=True on the returned DeprecationWrapperInfo. Add this filter to your CI pipeline to catch the pattern before it ships:

# phmdoctest:skip — CI template: replace my_package with your actual package
from deprecate import find_deprecation_wrappers
import my_package  # replace with your package, e.g. `import mylib`

inner_order = [r for r in find_deprecation_wrappers(my_package) if r.inner_order_property]
assert not inner_order, f"Inner-order @property @deprecated detected: {inner_order}"

Strict-mode import: Add from deprecate import property at the top of any module to replace the builtin property with a guard that raises TypeError at class-body evaluation time whenever it detects an already-@deprecated getter. The check fires before any instance is created:

# phmdoctest:skip — TypeError raised at class-body time
from deprecate import deprecated, property  # strict property replaces builtin


class MyClass:
    @property  # strict guard — raises TypeError immediately
    @deprecated(deprecated_in="1.0", remove_in="2.0")
    def value(self) -> int:
        return self._value

    # TypeError: Inner-order `@property @deprecated` detected on `MyClass.value`. ...

Modules that do not import the strict property keep builtin behaviour; the guard is purely opt-in.


TypeError: @deprecated cannot be applied twice to the already-deprecated property🔗

Q: I tried to stack two @deprecated decorators on a property and got TypeError: ... cannot be applied twice to the already-deprecated property. Why is this disallowed, and how do I add a setter or deleter to an already-deprecated property?

A: Double-wrapping a deprecated property would emit two FutureWarning notices per access and run the stacking guard three times. The decorator raises TypeError at decoration time so the misconfiguration cannot reach production. The error message names the offending property and points to the supported alternatives.

# phmdoctest:skip — intentional misconfiguration; raises TypeError at decoration time
from deprecate import deprecated


def _fget(self):
    return 0


# First @deprecated — fine.
once_wrapped = deprecated(deprecated_in="1.0", remove_in="2.0")(property(_fget))

# Second @deprecated on the already-wrapped property — TypeError at decoration time.
twice_wrapped = deprecated(deprecated_in="2.0", remove_in="3.0")(once_wrapped)
# TypeError: @deprecated cannot be applied twice to the already-deprecated property ...

Fix — apply @deprecated once, then use chain-style @value.setter / @value.deleter to add accessors. The outer-order subclass of property re-wraps the freshly-supplied accessor with the same packing config so warnings continue to fire on every access kind:

from deprecate import deprecated


class MyClass:
    def __init__(self) -> None:
        self._value = 0

    @deprecated(deprecated_in="1.0", remove_in="2.0")  # outer order — single application
    @property
    def value(self) -> int:
        return self._value

    @value.setter
    def value(self, new_value: int) -> None:
        self._value = new_value  # warning fires here too

    @value.deleter
    def value(self) -> None:
        self._value = 0  # warning fires here too


obj = MyClass()
obj.value = 9
print(obj.value)
Output: obj.value
9

Alternative — build the property explicitly with all three accessors and wrap once:

from deprecate import deprecated


def _fget(self):
    return self._value


def _fset(self, v):
    self._value = v


def _fdel(self):
    self._value = None


class MyClass:
    def __init__(self) -> None:
        self._value = 0

    # Single @deprecated wraps all three accessors at once — no chain stacking needed.
    value = deprecated(deprecated_in="1.0", remove_in="2.0")(property(_fget, _fset, _fdel))


obj = MyClass()
obj.value = 42
print(obj.value)
Output: obj.value
42

If you need to chain multiple migration steps across versions, the supported pattern is one @deprecated on the property plus chain-style accessors — not a second @deprecated on top.


@deprecated and targets with POSITIONAL_ONLY parameters🔗

Q: I applied @deprecated(target=new_fn) where new_fn has a positional-only parameter — does it work?

A: Yes, fully supported since v0.10. The decorator detects POSITIONAL_ONLY parameters at decoration time, emits a UserWarning advising you to remove / from the target signature if possible, and automatically splits the dispatch so positional-only values are forwarded positionally rather than as kwargs.

from deprecate import deprecated


def new_fn(val: int, /, y: int = 0) -> int:
    return val + y


@deprecated(target=new_fn, deprecated_in="1.0", remove_in="2.0")  # warns: UserWarning (POSITIONAL_ONLY detected)
def old_fn(val: int, y: int = 0) -> int: ...


print(old_fn(5))  # warns: FutureWarning
print(old_fn(val=5))  # warns: FutureWarning
print(old_fn(3, y=4))  # warns: FutureWarning
Output: 5 · 5 · 7
5
5
7

The thin-adapter pattern shown below still works and avoids the decoration-time UserWarning — use it when you want to silence the warning or keep the target signature unambiguous:

from deprecate import deprecated


def new_fn(val: int, /, y: int = 0) -> int:
    return val + y


def _new_fn_compat(val: int, y: int = 0) -> int:
    return new_fn(val, y)


@deprecated(target=_new_fn_compat, deprecated_in="1.0", remove_in="2.0")
def old_fn(val: int, y: int = 0) -> int: ...

deprecated_class applies the same treatment to constructor POSITIONAL_ONLY parameters — the proxy forwards remapped values positionally to the constructor (required positional-only params, frozen dataclasses, and __post_init__ logic all work) and emits a UserWarning at decoration time for visibility. Decorated sources declaring / parameters are fully supported as well, in all modes, with no decoration-time warning.


RuntimeError: Circular deprecation cycle detected🔗

When does this happen?

You pointed target= of a @deprecated wrapper at another @deprecated callable, and that second wrapper's target= chain eventually loops back to the first one. Calling any wrapper in the cycle raises RuntimeError at the first re-entry point. For generator wrappers it is raised on the first next() or async for step, because calling a generator wrapper runs nothing. The same error fires when a replacement recurses through the deprecated name while the wrapper is still on the call path (for a generator, before its first yield), so recurse through the replacement's own name instead.

# phmdoctest:skip
from deprecate import deprecated


@deprecated(target=new_fn, deprecated_in="1.0", remove_in="2.0")
def old_fn(x: int) -> int: ...


@deprecated(target=old_fn, deprecated_in="1.0", remove_in="2.0")
def new_fn(x: int) -> int: ...

Calling old_fn(1) raises:

RuntimeError: Circular deprecation cycle detected: `old_fn` re-entered via its own target chain.
Point to a non-deprecated final implementation.

Why?

@deprecated resolves its target= lazily at call time. When targets form a cycle, each call re-enters the same wrapper, which would recurse infinitely. The detector intercepts re-entry and raises RuntimeError instead of letting Python reach RecursionError.

Fix

Point target= at the plain (non-deprecated) final implementation, not at another @deprecated callable:

from deprecate import deprecated


def _impl(x: int) -> int:
    return x * 2


@deprecated(target=_impl, deprecated_in="1.0", remove_in="2.0")
def old_fn(x: int) -> int: ...


@deprecated(target=_impl, deprecated_in="1.0", remove_in="2.0")
def also_old_fn(x: int) -> int: ...

Both deprecated names forward to the same non-deprecated implementation with no cycle.

How do I type-annotate the object returned by deprecated_class / deprecated_instance?🔗

Q: What type do type checkers see for the result of deprecated_class(...) and deprecated_instance(...), and what should I annotate against?

A: A class alias is typed as the class it forwards to; an instance proxy is annotated with the public DeprecationProxy.

deprecated_class is typed to return its target: RetryConfig = deprecated_class(target=RetryPolicy, ...)(RetryPolicy) is type[RetryPolicy], so it needs no annotation and isinstance(x, RetryConfig) narrows to RetryPolicy. Without a class target (args_mapping / attrs_mapping only) the alias keeps the wrapped class's own type. deprecated_instance returns a proxy — annotate it with DeprecationProxy (never the private _DeprecatedProxy), spelled DeprecationProxy[T] when calling the proxy produces a T:

from dataclasses import dataclass
from deprecate import DeprecationProxy, deprecated_class, deprecated_instance


@dataclass
class RetryPolicy:
    attempts: int


# class alias — typed `type[RetryPolicy]`, no annotation needed
RetryConfig = deprecated_class(target=RetryPolicy, deprecated_in="1.4", remove_in="2.0")(RetryPolicy)


def default_policy() -> RetryPolicy:
    return RetryPolicy(3)


# instance proxy — annotated with the public protocol, so `legacy_default()` is typed `RetryPolicy`
legacy_default: DeprecationProxy[RetryPolicy] = deprecated_instance(
    default_policy, name="legacy_default", deprecated_in="1.4", remove_in="2.0"
)

print(isinstance(RetryConfig(3), RetryPolicy))  # warns: FutureWarning
print(legacy_default().attempts)  # warns: FutureWarning
Output: isinstance(RetryConfig(3), RetryPolicy); legacy_default().attempts
True
3

The class alias is still a proxy at runtime — isinstance(RetryConfig, DeprecationProxy) is True — but its proxy-only attributes (__wrapped__, __deprecation_config__) are invisible on type[RetryPolicy]. Read them with get_deprecation_config(RetryConfig) or inspect.unwrap(RetryConfig), or cast(DeprecationProxy[Any], RetryConfig) at the one site that needs them. See Classes → Type annotations and static analysis for what else type-checks against an alias.

issubclass raises TypeError

DeprecationProxy declares attributes (__wrapped__, __deprecated__), which makes it a data protocol. Python supports only isinstance for those: isinstance(obj, DeprecationProxy) works, issubclass(SomeType, DeprecationProxy) raises TypeError: Protocols with non-method members don't support issubclass(). This is a CPython rule for every runtime-checkable protocol with non-method members, not a pyDeprecate restriction.

mypy reports errors on deprecated class aliases after upgrading🔗

Q: After upgrading pyDeprecate, mypy reports new errors around deprecated_class aliases that used to pass. What changed?

A: Releases before v0.14 shipped no type information, so type checkers treated every pyDeprecate object as Any. Since v0.14 the package ships py.typed, and a deprecated class alias is typed as the class it forwards to — deprecated_class(target=New, ...)(Cls) gives type[New], and without a class target it gives type[Cls]; the @deprecated_class(...) decorator form is read as the class statement by mypy, while pyright applies the decorator's return type (type[New] with a class target). The errors that can surface, and their fixes:

  • [assignment] on Old: DeprecationProxy[New] = deprecated_class(target=New, ...)(Cls) — the alias is type[New], not a proxy type. Drop the annotation; the alias already has the replacement's type.
  • [attr-defined] on Old.__wrapped__ or Old.__deprecation_config__ — proxy-only attributes are invisible on the class type. Use get_deprecation_config(Old), inspect.unwrap(Old), or cast(DeprecationProxy[Any], Old).
  • [call-arg] / [attr-defined] on an old keyword or attribute name that the alias still accepts through args_mapping / attrs_mapping — the alias's class (the target, or the wrapped class without one) does not declare the old name, exactly as the decorator form has always reported. Keep the old names in a legacy class body, or type the alias as cast(DeprecationProxy[New], deprecated_class(...)(New)): calls and attributes become permissive, but isinstance against the alias no longer type-checks.
  • [valid-type] on def f(x: Old) (and, in mypy, class Child(Old)) — a call-form alias is a variable, and no type checker accepts a variable as a type. Annotate or subclass the replacement class, or (in mypy) use the decorator form, whose name is a class statement.

See Classes → Type annotations and static analysis.

Sphinx / my IDE shows (*args, **kwargs) instead of my deprecated class's real signature🔗

Q: Autodoc renders the proxy rather than the wrapped class, or inspect.signature() on a deprecated alias returns something useless. How do I reach the original?

A: Every proxy carries two breadcrumbs pointing back at what it wraps, and reading either one emits no deprecation warning — documentation builds and IDE hovers stay quiet:

Attribute Points to Consumed by
__wrapped__ the source class or object inspect.unwrap, Sphinx autodoc, griffe/mkdocstrings, IDEs
__signature__ the source's signature inspect.signature, and tools that do not walk __wrapped__
import inspect
from dataclasses import dataclass
from deprecate import deprecated_class


@dataclass
class RetryPolicy:
    attempts: int
    backoff: float = 1.0


@deprecated_class(target=RetryPolicy, deprecated_in="1.4", remove_in="2.0")
@dataclass
class RetryConfig:
    attempts: int
    backoff: float = 1.0


print(inspect.unwrap(RetryConfig).__name__)
print(str(inspect.signature(RetryConfig)))
Output: inspect.unwrap(RetryConfig).__name__; str(inspect.signature(RetryConfig))
RetryConfig
(attempts: int, backoff: float = 1.0) -> None

If a tool still renders the proxy, point it at __wrapped__ explicitly. When the wrapped object has no introspectable signature — a plain dict handed to deprecated_instance(), or any C-level type — __signature__ is None rather than missing; wrapping never fails because the source cannot be introspected.


How do I deprecate an entire Python module?🔗

Q: I am retiring a whole module, not a single function or class. Which API covers that, and how do callers get pointed at the replacement?

A: Call deprecated_module(__name__, deprecated_in=..., remove_in=...) at the bottom of the module you are retiring. Three modes are available:

  • Mode 1 (no target=) changes the module __class__ to an intercepting wrapper that delivers a FutureWarning on every public attribute access, including real attributes (functions, classes, constants already in __dict__).
  • Mode 2 (target=new_module) also forwards every missing-attribute lookup to the replacement module, optionally with attrs_mapping for per-name redirects. Use it when callers must be redirected to the new name transparently.
  • Mode 3 uses deprecated_instance() in the parent package __init__.py to expose the old module name as a deprecated attribute; no extra API is needed.

num_warns=-1 is the default and delivers on every access; 0 delivers none and a positive N delivers exactly N. See the module deprecation guide for each mode with runnable examples.


Does deprecated_module() warn when accessing real attributes (functions, classes, constants) on the deprecated module?🔗

Q: My deprecated module still defines its functions and constants. Do callers that use those real attributes get the warning, or only callers of names that no longer exist?

A: Yes, real attributes warn too. deprecated_module() changes the module's __class__ to a wrapper that overrides __getattribute__, so every public attribute access invokes the FutureWarning path, including attributes already in __dict__. Standard warning filters can suppress repeated display. A PEP 562 module-level __getattr__ hook would fire only for missing names; __getattribute__ intercepts all lookups. Star imports are not an exception (see below).


Module deprecation: accessing a missing attribute raises AttributeError🔗

Q: I called deprecated_module() in Mode 1 (no target=). Real attributes invoke the warning path correctly, but accessing a name that is not in the module raises AttributeError instead of just invoking that path. Is there a way to forward missing-attr lookups to a replacement module?

A: Yes — that is exactly what Mode 2 (target=new_module) does. Mode 1 intercepts every public attribute access (real or missing) via __getattribute__, so real attributes invoke the warning path and are returned. Standard warning filters can suppress repeated display. However, for names not in the module's __dict__, Mode 1 raises AttributeError after invoking that path because there is no target to forward to.

If you want unknown names to invoke the warning path and resolve, switch to Mode 2:

# phmdoctest:skip — CI template; new_calculator is not installed
# old_calculator.py — redirect missing-attr lookups to new_calculator
import new_calculator as _new_calculator
from deprecate import deprecated_module

deprecated_module(
    __name__,
    target=_new_calculator,
    deprecated_in="2.0",
    remove_in="3.0",
    message_template="Use `new_calculator` instead.",
)
# old_calculator.add(1, 2)  # invokes FutureWarning path + returns new_calculator.add(1, 2)

Can a context copied during a module redirect resolve the same name later?🔗

Q: A redirect target captures contextvars.copy_context() while resolving a missing attribute. Can later work in that context access the same deprecated attribute?

A: Yes. Redirect guards expire when the original lookup finishes, whether it returns a value or raises an exception. Later work in a copied context can resolve the same name normally. A cyclic redirect that re-enters the same name during an active lookup on the same thread still raises AttributeError.


Does from old_calculator import * still trigger the deprecation warning?🔗

Q: I used from old_calculator import * after calling deprecated_module() on it. Does that star import emit a FutureWarning, or does it slip through silently?

A: Yes, it still invokes the warning path. CPython's IMPORT_STAR bytecode resolves from module import * by calling getattr(module, name) once for each public name being pulled into the importing namespace (either the names in __all__, or all non-underscore names when __all__ is absent). Each of those getattr() calls routes through the module wrapper's __getattribute__ interception (_DeprecatedModuleWrapper) exactly like any other attribute access. Standard warning filters can suppress repeated display.

This holds for both Mode 1 and Mode 2 of deprecated_module(). It is also why deprecated_module() is implemented via __class__ reassignment plus __getattribute__ rather than a PEP 562 module-level __getattr__ hook: __getattr__ fires only for missing names and would never see the getattr() calls issued by IMPORT_STAR, leaving star imports completely unwarned. The __getattribute__ approach closes that gap.


Why does num_warns=-1 still appear in profiling?🔗

Q: I left deprecated_module(..., num_warns=-1) at its default. Why do repeated attribute accesses still show warning-related work, even though no warning-budget lock is taken?

A: -1 disables only budget accounting. Each public access still looks up module deprecation metadata and delivers the configured warning path; the standard warnings filter may suppress repeated display, but it still evaluates the warning. Use a finite num_warns when the warning delivery work itself must stop after a known number of accesses.


Why does stream=None not silence deprecated_module?🔗

Q: I passed stream=None to deprecated_module() expecting silence, as with @deprecated, but the module warning still appears, and a FutureWarning now fires at the deprecated_module(...) call itself.

A: For deprecated_module(), stream=None historically meant "use warnings.warn", while every other entry point treats it as silence. The default is now a separate sentinel (help() shows it as <warnings.warn>), so omitting stream keeps the current behaviour without any notice. An explicit stream=None still delivers through warnings.warn but announces the change with a FutureWarning at the call: in v1.0 stream=None silences the module warning, and the num_warns default changes from -1 to 1. Omit stream to keep warning on access, and pass num_warns=-1 explicitly if every access must keep warning after v1.0. To silence a module deprecation today, use a warnings filter or a custom stream that discards the message.


Why do pytest, Sphinx, or my IDE trigger warnings on a deprecated module?🔗

Q: I only imported a deprecated module once, but pytest collection, Sphinx autodoc, or my IDE/linter triggers many FutureWarnings. Why?

A: That is expected in Mode 1 and Mode 2. deprecated_module() overrides the module's __getattribute__, so any public attribute probe invokes the warning path even when the access comes from tooling rather than your own code. Standard warning filters can suppress repeated display; custom streams can still observe every permitted delivery. Test and documentation tools often call getattr(), inspect plugin metadata, or walk module attributes repeatedly during discovery.

If that noise is expected in tests, scope it in test config with warnings.filterwarnings instead of suppressing warnings globally:

# tests/conftest.py
import warnings

warnings.filterwarnings("ignore", category=FutureWarning, message=r"The `old_module` module was deprecated")

Replace old_module with the deprecated module's import path (escape dots in a dotted path, e.g. pkg\.old_module). Filter by message=, not module=: the warning is attributed to the module that accesses the deprecated one (the caller's frame), so module=r"^old_module$" never matches. The pattern above matches the start of the built-in notice ("The old_module module was deprecated since v..."); if you pass a custom message_template, match the start of that text instead. This keeps the warning visible elsewhere while silencing the noisy introspection path during test collection.


Does the deprecation survive copy, deepcopy, and pickle?🔗

Q: I copied (or pickled and restored) an object wrapped by deprecated_instance / deprecated_class — does the deprecation still apply to the copy?

A: Yes for copy.copy and copy.deepcopy. Copying a deprecated proxy returns a proxy again, so the deprecation warning travels with the object during the migration window. Warning counters are snapshotted at copy time, and each copy keeps its own counter independently. Copy operations are themselves silent — the copy protocol methods rebuild the proxy directly instead of going through attribute access, so no warning fires until the copy is actually used. Exception: a deprecated_class alias itself cannot be pickled in the common pattern where the proxy replaces the decorated class's module-level name — pickle cannot find the original class by reference and raises PicklingError. Instances of a class deprecated in place are not affected: they pickle and load as instances of the original class, and neither step warns. This includes exception and OrderedDict subclasses, so an exception raised in a process-pool worker still reaches the parent. A class with its own __reduce_ex__ keeps its own pickling, so members of an Enum deprecated in place still raise PicklingError. deprecated_instance proxies wrapping plain objects (dicts, lists, custom objects) are fully picklable.

import copy
import pickle

from deprecate import deprecated_instance

# NEW API — the canonical settings object going forward
NEW_LIMITS = {"max_retries": 3}

# DEPRECATED API — `OLD_LIMITS` kept as a deprecated alias during the migration window
OLD_LIMITS = deprecated_instance(NEW_LIMITS, name="OLD_LIMITS", deprecated_in="1.0", remove_in="2.0")

restored = pickle.loads(pickle.dumps(OLD_LIMITS))  # noqa: S301  # silent — pickling rebuilds the proxy, not a usage
duplicate = copy.deepcopy(OLD_LIMITS)  # silent

print(restored["max_retries"])  # warns: FutureWarning
print(duplicate["max_retries"])  # warns: FutureWarning — each copy has its own counter snapshotted from the original
Output: restored["max_retries"]; duplicate["max_retries"]
3
3

What do pickled instances of a class deprecated in place need to load?🔗

Q: I pickle instances of a class deprecated in place (@deprecated_class with no replacement class) into a cache or a worker queue. What does the process that loads them need, and do the payloads survive upgrading pyDeprecate or removing the deprecation?

A: The loading process needs pyDeprecate installed, and the class must still be deprecated in place when the payload loads. The module-level name of such a class holds the deprecation proxy, so the payload cannot name the class directly; it names one of pyDeprecate's rebuild helpers (deprecate.proxy._rebuild_class_instance, _rebuild_class_instance_ex or _rebuild_class_call) together with the class's module and qualified name.

  • Upgrading pyDeprecate is safe. The helper names are part of the pickle compatibility contract and stay unchanged across releases, so a payload written by one release loads in the next. pyDeprecate v0.14 and earlier have no such helpers, so they cannot load these payloads.
  • Removing the deprecation breaks loading. The helpers rebuild only a class whose name a deprecation proxy holds. Once the deprecation is gone and the name points at the plain class again, loading raises AttributeError: Cannot rebuild the pickled instance: ... is not a class deprecated in place.

To migrate stored instances, load them while the deprecation is still in place, then set the module attribute back to the real class in the migration process and re-pickle. Re-pickling while the proxy still holds the name does not migrate anything: the reducer looks the published name up when it runs, so it writes another helper-dependent payload that fails to load once the deprecation is removed. With the name bound to the real class the default reduction applies, and payloads written then name the class directly and need nothing from pyDeprecate.


My expiry gate reports more expired wrappers after upgrading🔗

Q: After upgrading pyDeprecate, validate_deprecation_expiry (and pydeprecate expiry) suddenly reports more expired wrappers than before — nothing in my code changed. Why?

A: validate_deprecation_expiry now includes deprecated class members by default (include_members=True), matching find_deprecation_wrappers and generate_deprecation_table. Expired member wrappers — for example an __init__ carrying deprecation metadata — are now counted, so totals can rise after upgrading even though your code is unchanged. The newly reported entries are real expired deprecations that were previously invisible to the gate.

from deprecate import validate_deprecation_expiry

# For testing purposes, we use the test module; normally you would import your own package
from tests.collections.api_deprecated import core as my_package

# Default scope now includes deprecated class members
expired = validate_deprecation_expiry(my_package, "0.5")
print(f"Found {len(expired)} expired")

# Restore the previous module-level-only scope
expired = validate_deprecation_expiry(my_package, "0.5", include_members=False)
print(f"Found {len(expired)} expired")
Output: Found ... expired
Found 31 expired
Found 28 expired

Prefer fixing the newly surfaced entries over passing include_members=False — the narrower scope hides genuinely expired member deprecations. Every audit function takes the same include_members switch, and every CLI subcommand takes --no-include-members (for example pydeprecate expiry src/mypackage --no-include-members).


Why does pydeprecate all report policy violations but still exit 0?🔗

Q: pydeprecate all src/mypackage prints a Deprecation Policy Violations table, but the command exits 0 and my CI step passes. Is the exit code wrong?

A: No — that is deliberate. The policy defaults (min_grace="0.3", message_required=True) encode a release convention, not a universal rule, so all runs the policy check in advisory mode: violations are printed for visibility but never contribute to all's exit code. Only invalid argument mappings, misconfigured targets, inner-order properties, deprecated-to-deprecated chains, and expired wrappers make all exit 1. This "advisory" treatment covers violation counts only — a policy configuration failure (the nearest pyproject.toml cannot be read or parsed, or a [tool.pydeprecate.policy] value is malformed) still exits 2 even inside all, printing Cannot read <path>: <err>, and that failure is checked before the chains check runs.

To make the build fail on a policy violation, give pydeprecate policy its own CI step — its exit code is truthful (0 clean, 1 violations, 2 a malformed rule argument):

# reports policy violations, but the exit code ignores them
pydeprecate all src/mypackage

# this is the step that fails the build
pydeprecate policy src/mypackage --min-grace=0.3 --message-required=True

Spell the rule flags out in that step even where they match the defaults: the command then records what your project promises, and a future change to pyDeprecate's defaults cannot quietly change what your CI enforces.


How do I switch off a policy rule my project does not follow?🔗

Q: My project removes deprecated code one minor after announcing it, so pydeprecate policy flags every wrapper with [min-grace]. I do not want to abandon the guidance rule to silence it.

A: Both rules are independently controlled — pass None for min_grace or False for message_required. Nothing is all-or-nothing.

Before reaching for None, check whether retargeting the window is what you actually want. min_grace is a version-shaped delta: "0.1" asks for one minor of warning, "0.3" (the default) for three, "1.0" for a whole major. Pass the number your project really releases on — you keep the gate, pointed at your own cadence. Disabling it means nothing checks removal schedules at all. A 0.x project needs no special setting: a bump to 1.0 is a major step and clears any window.

Here is the difference between the two:

from deprecate import validate_deprecation_policy

# For testing purposes, we use the test module; normally you would import your own package
from tests.collections import policy as my_package

# Relax the window: one minor of warning is enough.
# The fixtures still trip it — one removes in the same release, one at a patch off any boundary.
violations = validate_deprecation_policy(my_package, recursive=False, min_grace="0.1")
print(f"Found {len(violations)} violations")

# Or drop the rule entirely and keep the guidance rule
violations = validate_deprecation_policy(my_package, recursive=False, min_grace=None)
print(f"Found {len(violations)} violations")
Output: f"Found {len(violations)} violations"
Found 5 violations
Found 3 violations

The CLI mirrors this one flag per rule — --min-grace=0.1, --min-grace=None, --message-required=False. A value the parser does not recognise is rejected before the scan starts and exits 2 with a message naming the accepted spellings, so a typo can never silently disable a rule you meant to keep.


Where does pydeprecate policy take its rules from when I pass no flags?🔗

Q: I run a bare pydeprecate policy src/mypackage in CI and locally. Which rules apply, and can the project declare them once instead of every invocation repeating --min-grace and --message-required?

A: Each rule is resolved independently as flag → [tool.pydeprecate.policy] in pyproject.toml → built-in default (min-grace = "0.3", message-required = true). Declare the project's convention once:

[tool.pydeprecate.policy]
min-grace = "0.3"  # quoted — a TOML float 0.10 reads as 0.1; false switches the rule off
message-required = true

The file is found the same way --version auto-detection finds it — from the scanned path's directory up to two parents, nearest table wins — and never for a bare module name, so running from inside an unrelated checkout cannot adopt its policy. Every run prints a Policy: header naming the source of each value (flag, pyproject.toml, built-in), so a CI log always shows which convention was enforced. A malformed value from the file, or a pyproject.toml that cannot be read or parsed at all, exits 2 and the message names the file (Cannot read <path>: <err>); an unknown key (the usual typo is min_grace with an underscore) is reported on stderr and ignored, never silently enforced. On Python 3.9–3.10 the parser comes from the [audit] extra (tomli); without it a reachable pyproject.toml triggers an advisory rather than being skipped in silence. pydeprecate all applies the same resolved settings to its advisory policy pass — but this config-read exit 2 is the one exception to "advisory": it still propagates through all (checked before chains runs) even though violation counts themselves do not. The Python API validate_deprecation_policy() never reads pyproject.toml — it takes a module (object or importable name), never a filesystem path, so pass min_grace and message_required explicitly there.


How do I keep test fixtures or a legacy tree out of the audit?🔗

Q: My package ships my_package.tests (or a frozen my_package._legacy tree) with deliberately odd @deprecated wrappers. pydeprecate all reports them, and importing the fixtures is slow. Can the scan skip them?

A: Yes — every scanning function and every CLI subcommand takes an exclude list of glob patterns over the full dotted module name:

[tool.pydeprecate]
exclude = ["my_package.tests", "*._legacy*"]

or pydeprecate all src/my_package --exclude='my_package.tests,*._legacy*' (quote it, or the shell expands the * first), or find_deprecation_wrappers(my_package, exclude=["my_package.tests"]) in Python. A pattern that matches a package excludes its whole subtree, and the scan itself never imports an excluded package — so a fixture that raises at import time neither slows the scan nor produces an audit: skipped warning, as long as nothing else in your package imports it (if something does, the import happens as usual and only the wrappers are filtered out). The header line Exclude: my_package.tests (pyproject.toml) shows what was left out and where the list came from. Write the full name (my_package.tests), not a bare tests: patterns are matched against dotted module names, so a bare word matches only a top-level module of that name. A value that is not a string or a list of strings exits 2.


UserWarning: audit: skipped <module> during a recursive scan🔗

Q: A recursive audit scan (find_deprecation_wrappers, validate_deprecation_expiry, pydeprecate check / all) emits UserWarning: audit: skipped <module>: <exception>. What does it mean?

A: Recursive scans import every submodule of the scanned package, so module-level code runs. When a submodule raises any exception at import time — not just ImportError — the scanner emits this warning and continues with the remaining modules instead of aborting the whole scan:

UserWarning: audit: skipped my_package.legacy_ext: RuntimeError('optional native extension not built')

The scan results are complete for every module that imported successfully; only the named submodule is missing. Investigate and fix that submodule (or accept the gap if the failure is expected, e.g. an optional dependency) — one broken submodule no longer kills the CI gate, but its deprecations are not audited until it imports cleanly. The CLI also prints pydeprecate gap: module not audited: <module>: <exception> on stderr for each skipped submodule; that line appears even when your package installs a warnings filter that hides the UserWarning.


My pydeprecate pre-commit hook passes but misses deprecations🔗

Q: I added the pydeprecate pre-commit hook and it always passes, but pydeprecate check in my virtual environment reports problems. Why?

A: The remote hook (repo: https://github.com/Borda/pyDeprecate) runs in an isolated environment that pre-commit builds for it. That environment holds pyDeprecate and its CLI dependencies, but not your package's runtime dependencies. The scan imports your package, so every submodule that imports a missing dependency is skipped with UserWarning: audit: skipped <module>, and pre-commit hides the output of a hook that passes.

Two fixes:

  • Run a local hook (repo: local, language: system, entry: pydeprecate check) so the scan uses your development environment — the recommended setup for any package with runtime dependencies.
  • Keep the remote hook and list your dependencies in additional_dependencies. The list replaces the hook's own, so start it with the hook's "pyDeprecate[cli,audit]" entry (pyDeprecate's cli and audit extras, which pip resolves to the hook's own checkout) and then append yours — without the audit extra, packaging is missing, so expiry and the min-grace policy rule are skipped with a warning instead of failing, and without tomli on Python 3.9–3.10 [tool.pydeprecate] settings are ignored.

Both recipes are in the audit guide.


My pydeprecate pre-commit hook fails: contains multiple packages🔗

Q: The pydeprecate pre-commit hook fails with contains multiple packages (mypkg, tests) and a non-zero exit. What should I change?

A: With no path in args, the hook looks for a single package under src/, then at the repository root. A root that holds two or more top-level packages (here mypkg/ and tests/) is ambiguous, so pyDeprecate stops with an error instead of scanning nothing and passing. Src-layout repositories and single-package roots resolve on their own. For a multi-package repository, name the package to audit in the hook args:

- id: pydeprecate-check
  args: [mypkg]

The same error, a usage error with exit 2, applies to every subcommand; see the audit guide.


pydeprecate gap: other copy audited🔗

Q: The CLI prints pydeprecate gap: other copy audited: <pkg> was imported from <dir>, not from <scanned dir>. Were my deprecations checked?

A: The ones of the copy named in the message were — not necessarily the ones in the directory you passed. A package is imported by name, and when that name is already taken in the CLI's process the scan gets the loaded copy: pyDeprecate's own deprecate package (scanning a fork or another checkout of pyDeprecate), or a package an editable install of another checkout resolves ahead of sys.path. The header line Imported from: names the file that was actually audited. When that copy is the same code — CI installs a wheel built from the checkout and then scans src/ — the result is still right. Otherwise run the scan from the checkout's own virtual environment, where the package name resolves to the directory you pass.


TypeError: <obj> is wrapped in a deprecation proxy and cannot be pickled directly🔗

Q: I get TypeError: ... is wrapped in a deprecation proxy and cannot be pickled directly when passing a deprecated_instance proxy to pickle.dumps, multiprocessing, or joblib.

A: This error was raised in older versions (before PR #212). Since PR #212, pickle.dumps(proxy) works for deprecated_instance proxies: the proxy is reconstructed on unpickle with deprecation semantics preserved. If you still see this error on a deprecated_instance proxy, upgrade pyDeprecate. Two remaining caveats: (1) the stream callable must be picklable by reference — if you passed a lambda or closure as stream, use stream=None or stream=warnings.warn instead; (2) a deprecated_class alias itself raises PicklingError in the common pattern where the decorated class name is replaced by the proxy — use copy.deepcopy for class aliases instead; instances of a class deprecated in place pickle fine.


My deprecated function raises DeprecatedError instead of warning🔗

Q: A call that used to emit a FutureWarning now raises deprecate.DeprecatedError. Nothing in my code changed.

A: Something switched the deprecation to fatal. Four places to check, in order: the wrapper itself (as_exception=True on the @deprecated / deprecated_class / deprecated_instance / deprecated_module call), an enclosing with deprecate.as_exceptions(): block in the same thread or task, the process-wide deprecate.AS_EXCEPTIONS attribute (an application bootstrap or a test fixture may set it), and the DEPRECATE_AS_EXCEPTIONS environment variable, which seeds that attribute at import when set to 1, true, yes, or on. Precedence is monotonic, so as_exception=False on the wrapper does not escape a True global — that is deliberate, so a strict CI run cannot be opted out of through a wrapper's own as_exception flag (only the consumer's own as_exceptions(False) scope or AS_EXCEPTIONS setting can). To find the source, print deprecate.AS_EXCEPTIONS and os.environ.get("DEPRECATE_AS_EXCEPTIONS") at the failing call site, and read get_deprecation_config(the_wrapper).as_exception for the wrapper's own value. An open as_exceptions() scope never shows in deprecate.AS_EXCEPTIONS; if all three read False, look for a with as_exceptions(): block or an @as_exceptions() decorator in the call stack of the failing call (the traceback lists it).


Why does as_exception ignore num_warns, and raise even with stream=None?🔗

Q: I set num_warns=1 expecting one exception, or stream=None expecting silence, but every call to my fatal deprecation still raises.

A: Both are intended. num_warns budgets messages; a gate that stopped refusing after the first call would let a caller retrying inside except DeprecatedError straight through, so a fatal deprecation bypasses the budget and raises on every call, leaving the warn counters untouched. stream=None silences the message, not the lifecycle state — the raise is the gate, and making an unrelated cosmetic setting switch it off would be a trap in CI. The one switch that does suppress a fatal deprecation is skip_if: a wrapper turned off by configuration is not a deprecation at that moment. Note the flip side for whole modules: a fatal deprecated_module raises on every public attribute access, which includes attribute-walking tools such as pytest collection, Sphinx autodoc, pickle, and copy — expect those to stop at the module rather than skip it. pyDeprecate's own audit is unaffected — find_deprecation_wrappers() and the pydeprecate CLI read the module's __dict__ instead of going through attribute access, so a fatal module is still discovered and reported normally.


Why did a failed proxy[key] lookup not warn?🔗

Q: My code probes a deprecated mapping with try: settings[key] except KeyError: .... The miss emitted no FutureWarning, but the next successful settings["threshold"] read did.

A: Synchronous data-use operations warn only after they succeed: a KeyError from proxy[key] or an AttributeError from hasattr emits nothing and leaves the num_warns budget unspent. next(proxy) is the exception: it warns before advancing the wrapped iterator, so a failed warning delivery loses no item and an exhausting next() (StopIteration) spends the budget. Async iteration and awaiting are different: __anext__ and __await__ warn when they obtain the underlying awaitable, before it runs, so a later async failure or exhaustion still consumes the budget. If warning delivery raises after context entry acquires a resource, the proxy invokes that same context’s exit method before propagating the error. Calling the proxy also warns before the call, like @deprecated, so a deprecated constructor that raises still reports the deprecation. With as_exception=True, the proxy raises DeprecatedError before it touches the wrapped object for subscripts, iteration, unary and conversion operators, next(), with and async with entry, async iteration, await, in-place operators such as +=, and attrs_mapping attribute reads — a missing key under a fatal deprecation therefore raises DeprecatedError, not KeyError, and a defaultdict gains no entry.


Can a deprecated_instance of a function be used as a class attribute (method)?🔗

Q: I replaced a method with class Model: scale = deprecated_instance(legacy_scale, ...). Does Model().scale(2) still pass the instance to legacy_scale?

A: Yes. The proxy implements the descriptor protocol: read through an instance, a proxy over a plain function (or a @deprecated wrapper) returns a bound method whose __func__ is the proxy, so Model().scale(2) calls legacy_scale(model, 2) and warns on the call, pointing at the calling line. The num_warns budget belongs to the proxy, so the default single warning is shared by every instance, and skip_if and as_exception behave as on a direct call. Read through the class, Model.scale is the proxy itself, as a plain function would be, so Model.scale(model, 2) works too. Proxies over data, classes, and non-function descriptors (property, classmethod, staticmethod objects) are returned as stored and never bound; to deprecate one of those, decorate the underlying function with @deprecated instead. Releases without this binding called legacy_scale(2) and raised TypeError: legacy_scale() missing 1 required positional argument.


Why don't mypy, pyright, or my IDE flag functions deprecated with pyDeprecate?🔗

Q: My deprecated function warns at runtime, but the editor shows no strikethrough and mypy / pyright report nothing when I call it.

A: Type checkers recognise a deprecation only when the decorator is literally warnings.deprecated (Python 3.13+) or typing_extensions.deprecated — they match the decorator by name, so no runtime decorator (pyDeprecate's included) can make them see it. Stack the PEP 702 decorator directly above pyDeprecate's: @warnings.deprecated("Usenew_nameinstead.", category=None) over @deprecated(...). Keep category=None (otherwise every call warns twice), write the message as a string literal (mypy silently ignores a constant or f-string), and import the module rather than the name so pyDeprecate's deprecated is not shadowed. mypy only reports it with enable_error_code = ["deprecated"], pyright in CI with reportDeprecated = "error". Over @deprecated_class(...), both mypy and pyright flag the old class name. See Functions → Static type checkers.


Why does my deprecation warning point inside pyDeprecate or functools.py?🔗

Q: A deprecation FutureWarning (or a pyDeprecate UserWarning) reports a file inside the deprecate package or functools.py instead of my line, so my module-scoped warning filter or -W error traceback does not match my code.

A: pyDeprecate measures the location when a warning fires and reports the first frame outside the deprecate package. That holds for call-time notices, decoration-time configuration warnings and audit diagnostics, including stacked @deprecated layers, the @deprecated class front door and legacy target=None/True/False sentinels. Two cases still report a different frame:

  • A custom stream written in Python. pyDeprecate passes the stacklevel counted from the frame that calls the stream. A Python function that forwards it to warnings.warn adds its own frame, so the warning lands one frame short, inside pyDeprecate. Use warnings.warn, a functools.partial of it, or a bound logger method such as logging.getLogger().warning as the stream, or add 1 to the stacklevel your stream receives before forwarding it. The module-level logging.warning is correct only on Python 3.11 and newer; on 3.9 and 3.10 it adds one logging frame, so the log record points one frame short.
  • A @cached_property source. The getter runs inside functools.cached_property.__get__, which is the first frame outside pyDeprecate, so the warning reports functools.py. Filter on the warning category or message instead of the module.

My singledispatch or __signature__-patched source receives keyword arguments🔗

Q: A deprecated source built with functools.singledispatch, or one whose __signature__ differs from its real call shape, fails with TypeError: ... requires at least 1 positional argument, receives keywords it did not expect, or rejects a call the plain function accepts.

A: A TargetMode.NOTIFY wrapper, and a TargetMode.ARGS_REMAP wrapper whose caller uses no deprecated argument name, calls the source with the caller's arguments unchanged, so positional dispatch keeps working there. Two cases still differ from calling the source directly:

  • A caller passes a deprecated argument name to a TargetMode.ARGS_REMAP wrapper. To rename the argument, the wrapper rewrites the whole call into keyword arguments, whether the old name was passed by keyword or filled positionally; a wrapper that sets args_extra rewrites every call the same way. A source that dispatches on its positional arguments then receives keywords. Keep the dispatching function out of the rename: decorate a plain def that accepts the old and new names and calls the dispatcher positionally.
  • A __signature__ that reports fewer positional parameters than the source accepts. Before calling the source, the wrapper checks the number of positional arguments against inspect.signature(source), so a call that fills more slots than the patched signature lists raises the curated TypeError (... takes N positional argument(s) but got M positional argument(s)) even though the real source would accept it. Make __signature__ list every positional slot, or give it a *args parameter.

Why does my CI step exit 3?🔗

Q: pydeprecate check src/mypackage (or the GitHub Action) fails with exit code 3 and pydeprecate: scan error: … or pydeprecate: internal error: …. What does it mean?

A: The audit did not run, so there are neither findings (exit 1) nor a usage error (exit 2). A scan error means the scanned package itself could not be imported: its __init__ (or the module you named) raised, or its module-level code called sys.exit(). Install the package's runtime dependencies in the environment that runs the scan, as you would for its tests; a submodule that fails to import is only reported as a pydeprecate gap: module not audited line and keeps the exit code. An internal error is a bug in pyDeprecate; the message names the exception and the issue tracker to report it to. --exit-zero does not mask exit 3, and no --report-json file is written. Before 0.15 both cases exited 1, like findings.


Why does pydeprecate check now fail on a wrapper it used to pass?🔗

Q: After upgrading, pydeprecate check (and all) exits 1 with Found deprecated wrappers with a misconfigured target or Found inner-order @property @deprecated wrappers, on code that passed before.

A: Since 0.15 both are errors, as the CLI guide always promised. A misconfigured target is a wrapper whose target contradicts the rest of its configuration: TargetMode.NOTIFY with an args_mapping, attrs_mapping or args_extra it ignores (callers of the old argument name are never migrated), TargetMode.ARGS_REMAP without an args_mapping (nothing to remap), or the invalid target=False. Fix the target: TargetMode.ARGS_REMAP to rename arguments, or drop the mapping for a plain notice. An inner-order property is @property above @deprecated: only the getter warns, and a setter or deleter added later never does. Put @deprecated(...) above @property instead. The scan lists the same rows in find_deprecation_wrappers through the misconfigured_target and inner_order_property fields.


Why did the GitHub Action skip my job summary?🔗

Q: The action ran with summary: true, but the job summary is absent or warns that the gate wrote no pyDeprecate report, or that no pyDeprecate report is available, or another runner warning about the summary.

A: The summary is optional and never changes the gate's exit code. The action takes the table from the report the CLI writes with --report-json, and the CLI writes that report only when the scan ran to the end with exit 0 or 1. "The gate wrote no pyDeprecate report" means the report could not be written; the cause is in the CLI's stderr just above the warning. A usage error (exit 2) and a crashed scan (exit 3, for example a package that fails to import) write no summary and no warning, because no scan finished; the step's error annotation names the exit code. "No pyDeprecate report is available" means the job runs a pyDeprecate older than --report-json with install: false: keep install: true, or upgrade the pyDeprecate your job installs. The same "no report is available" warning also appears when the runner could not create the temporary directory for the report (its preceding warning gives the OS error). Other warnings: "summary: true needs GITHUB_STEP_SUMMARY" means the job is not on a GitHub runner, and "could not write the job summary" shows the OS error; both are self-describing. "The gate's report holds no deprecation table" means the CLI could not render the table (the reason is on its stderr). "Could not read the pyDeprecate report" or "not of layout version" means the report file is corrupt or comes from a different pyDeprecate than the action's (for example install: false with another version); the preceding runner warning names the cause, and the gate result is unaffected.


Still stuck?🔗

Open a GitHub issue

If none of the above covers your situation, open an issue on GitHub Issues. Include the full traceback, the decorator call you used, and the Python and pyDeprecate versions (pip show pyDeprecate).