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: Direct use of @deprecated on class MyClass is deprecated since v0.6.0. Use @deprecated_class(...) instead. This will become a TypeError in a future release. Why, and how do I fix it?

A: Use @deprecated_class() for classes. The @deprecated decorator is designed for functions and methods only.

That warning is triggered specifically when @deprecated is applied directly to a class. This still works today because pyDeprecate delegates to @deprecated_class() under the hood, but that delegation path is itself deprecated and will become a TypeError in a future release. The warning is telling you to switch to the explicit class API now.

This delegation will become a TypeError in a future release

The implicit fallback from @deprecated to @deprecated_class() is a temporary compatibility shim. Once it is removed, applying @deprecated to a class will raise TypeError immediately at decoration time. Migrate now to avoid a hard break on upgrade.

There are two supported alternatives depending on what you need. Use @deprecated_class() when you want to deprecate the class name itself (including Enums and dataclasses). Use @deprecated on __init__ when you want to emit a deprecation notice only at instantiation time while keeping the class name in place.

from deprecate import TargetMode, deprecated_class
from enum import Enum


# Correct: use @deprecated_class for classes
@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 MyClass:
    @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 MyClass(...)


print(MyClass(5).val)
Output: MyClass(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

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 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.

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


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

from deprecate import deprecated


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


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


# Also correct: use a lambda
@deprecated(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.

# 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.

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 _DeprecatedProxy itself — and they are.

What warns (data-use operations):

  • Arithmetic 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)
  • Subclassing (class Child(proxy):)

What stays silent (structural probes):

  • Ordering comparisons: <, <=, >, >=
  • 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 without an explicit callable target, 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

To emit a deprecation notice for every instantiation regardless of which argument name is used, configure target=TargetMode.NOTIFY explicitly. Combining TargetMode.NOTIFY with args_mapping is a misconfiguration — args_mapping is not applied under NOTIFY and supplying it emits a construction-time UserWarning today that becomes a TypeError in v1.0.


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


@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 = DepConfig.__deprecated__
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.


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.

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 classestype("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 fires the warning before I iterate it🔗

Q: My generator function is decorated with @deprecated. The deprecation warning fires as soon as I call the function — before I even call next() or iterate over it. Is this a bug?

A: No — this is the intended behavior. pyDeprecate uses an eager factory pattern for generator wrappers: the warning fires at call time, consistent with how deprecated regular functions behave.

When you call a deprecated generator function, you get the deprecation notice immediately at the call site — the same place you would see it for a deprecated regular function. You can then pass the generator around, iterate it later, or hand it to another function, and the warning is already recorded.

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):
    return void(start, count)


# Warning fires here — at call time
gen = iter_ids(10, 3)
# FutureWarning: The `iter_ids` was deprecated since v0.9 in favor of `generate_ids`.

# Iteration is normal — warning already fired above
print(list(gen))  # [10, 11, 12]
Output: list(gen)
[10, 11, 12]

My deprecated async generator fires the warning before I iterate it🔗

Q: My async generator function is decorated with @deprecated. The deprecation warning fires as soon as I call the function — before my async for loop starts. Is this a bug?

A: No — this is the intended behavior. pyDeprecate wraps async generator sources with a sync callable. Calling the wrapper immediately fires the deprecation warning and returns the underlying async generator object; no await or __anext__ call is needed for the warning to appear.

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


# Warning fires here — at sync call time, before any iteration
agen = old_stream(3)  # FutureWarning: The `old_stream` was deprecated since v0.9 ...


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


# Iteration proceeds normally
asyncio.run(consume(agen))

Note: Because the wrapper is a sync function, inspect.isasyncgenfunction(old_stream) returns False. Frameworks that check this flag may misclassify the wrapper — wrap it in a thin async def passthrough if introspection matters.


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: The num_warns warning quota is enforced by a lock and is thread-safe for synchronous wrappers — exactly num_warns warnings fire regardless of how many threads call the deprecated function simultaneously. For concurrent async coroutines sharing one deprecated wrapper, a race on the warning counter is still possible: one coroutine may read the counter, another may increment it, and the first may then emit or skip based on a stale value. The result is that fewer warnings than num_warns specifies may be emitted under high async concurrency.

Workaround for async concurrency: Set num_warns=-1 to bypass the count gate entirely. With num_warns=-1 the warning fires unconditionally on every call, so no race can suppress it.

import asyncio
from deprecate import deprecated


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


# num_warns=-1 emits the deprecation notice on every call regardless of concurrency
@deprecated(target=new_fetch, deprecated_in="0.9", remove_in="1.0", num_warns=-1)
async def old_fetch(url: str) -> bytes:
    pass


async def main():
    urls = ["https://a.example.com", "https://b.example.com", "https://c.example.com"]
    # All three tasks fire the deprecation notice — no race suppression possible
    await asyncio.gather(*[old_fetch(u) for u in urls])


print(asyncio.run(main()) is None)
Output: asyncio.run(main()) is None
True

If you need to assert exactly one warning fires in a test, run the deprecated coroutines sequentially rather than with asyncio.gather.


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 callwith 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 fire eagerly at call time; async functions are the exception because the wrapper itself is a coroutine.


I wrapped a functools.partial of an async def and the wrapper is not async🔗

Q: I applied @deprecated to a functools.partial of an async def function, but the resulting wrapper is synchronous — inspect.iscoroutinefunction(wrapper) returns False and await wrapper(...) raises a TypeError. What is happening?

A: On Python 3.9–3.11, inspect.iscoroutinefunction(functools.partial(async_fn)) returns False — the partial object does not propagate the coroutine flag of its wrapped callable. pyDeprecate's async branch in packing() checks inspect.iscoroutinefunction(source) to decide whether to produce an async def wrapper, so wrapping a partial of an async def falls through to the sync wrapper path and yields a regular function instead of a coroutine function.

Workaround: Apply @deprecated to the async def directly, then use functools.partial on the already-deprecated async wrapper if you need preset arguments.

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

This limitation is resolved in Python 3.12+ where inspect.iscoroutinefunction correctly handles functools.partial objects whose underlying callable is an async def.


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.

# 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.


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: deprecated_class proxies 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. 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

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 import collection_deprecate 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.


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.


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) deprecated_class proxies raise PicklingError in the common pattern where the decorated class name is replaced by the proxy — use copy.deepcopy for class aliases instead.


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