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:
Not:
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, x: int) -> None:
self.x = x # body still executes; warning fires on every new MyClass(...)
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
# --- Old broken pattern (pre-v0.6) ---
from deprecate import deprecated
class NewClass:
pass
@deprecated(target=NewClass, deprecated_in="1.0", remove_in="2.0")
class OldClass:
class_attr = 42
obj = OldClass()
isinstance(obj, OldClass) # TypeError or False — OldClass is now a function
issubclass(OldClass, object) # TypeError — same reason
OldClass.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 NewCalculator:
def add(self, a: int, b: int) -> int:
return a + b
@deprecated_class(target=NewCalculator, deprecated_in="1.0", remove_in="2.0")
class OldCalculator:
pass
obj = OldCalculator()
print(isinstance(obj, NewCalculator)) # True — proxy forwards isinstance checks
print(issubclass(OldCalculator, object)) # True — type checks pass through
print(obj.add(1, 2)) # 3 — forwarded to NewCalculator
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 Color(Enum):
RED = 1
BLUE = 2
OldColor = deprecated_class(target=Color, deprecated_in="1.5", remove_in="2.0")(Color)
print(OldColor.RED is Color.RED) # True
print(OldColor(1) is Color.RED) # True
print(OldColor["RED"] is Color.RED) # 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
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(required_arg: int, **kwargs) -> int:
return required_arg * 2
# ---------------------------
from deprecate import deprecated
# None means skip this argument
@deprecated(target=new_func, args_mapping={"old_arg": None})
def old_func(old_arg: int, new_arg: int) -> int:
pass
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"})
def old_func(old_name: int) -> int:
pass
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"})
def my_func(old_arg: int = 0, new_arg: int = 0) -> int:
return new_arg * 2
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 new_func() -> str:
return "Hi!"
# ---------------------------
from deprecate import deprecated
# Correct: function returns bool
def should_skip() -> bool:
return False # replace with your condition
@deprecated(target=new_func, skip_if=should_skip)
def old_func1():
pass
# Also correct: use a lambda
@deprecated(target=new_func, skip_if=lambda: False)
def old_func2():
pass
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 new_func(x: int) -> int:
return x * 2
# ---------------------------
from deprecate import deprecated
# Show warning every time
@deprecated(target=new_func, num_warns=-1) # -1 means unlimited
def old_func_always_warn():
pass
# Show warning N times total
@deprecated(target=new_func, num_warns=5) # Show 5 times
def old_func_warn_n_times():
pass
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.
Why does deprecated_instance not emit a notice on arithmetic/comparison operators?🔗
Q: I wrapped a float constant with deprecated_instance but operations like old_value + 1 or old_value > 0 do not emit any deprecation notice. Why?
A: Python's data model invokes special ("dunder") methods like __add__, __lt__, __mul__, etc. directly on the object's type, bypassing __getattr__. The _DeprecatedProxy class implements __getattr__ to intercept attribute access, but CPython does not call __getattr__ for implicit protocol method lookups (it goes through the class's MRO directly). Since _DeprecatedProxy does not define every possible arithmetic/comparison dunder, these operations fall through to the default behaviour or raise TypeError — without emitting a deprecation notice.
The proxy does intercept:
- Attribute access (
obj.name) via__getattr__ - Subscript access (
obj[key]) via__getitem__ - Iteration (
for x in obj) via__iter__ - Calling (
obj(...)) via__call__ - Equality (
obj == other) via__eq__ - Boolean truth (
if obj) via__bool__ - String representation (
str(obj),repr(obj)) via__str__/__repr__
It does not intercept:
- Arithmetic operators (
+,-,*,/,//,**,%) - Comparison operators (
<,>,<=,>=) other than equality - Bitwise operators (
&,|,^,~,<<,>>) - Unary operators (
-obj,+obj,abs(obj))
Known limitation: proxy cannot intercept dunder protocol methods
This is a fundamental CPython constraint, not a pyDeprecate bug. Wrapping primitives (int, float, str) in deprecated_instance will not emit notices for arithmetic, comparison, or bitwise operations. See the workarounds below.
Workarounds for primitive constants:
- Wrap in a container — put the value in a dict or dataclass so access goes through
__getitem__or__getattr__:
from deprecate import deprecated_instance
# Instead of: OLD_THRESHOLD = deprecated_instance(0.5, ...)
# Use a container:
_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"])
- Update call sites directly — for simple numeric or string constants that are used in expressions, it is often simpler to rename the constant and update references rather than wrapping in a proxy:
# Just rename and grep-replace call sites:
NEW_THRESHOLD = 0.5 # new name
# OLD_THRESHOLD = 0.5 # remove after migration
- Use a deprecated function wrapper — if you need deprecation notices on read access to a bare value, expose it through a function that you can decorate:
from deprecate import TargetMode, deprecated
@deprecated(target=TargetMode.NOTIFY, deprecated_in="1.0", remove_in="2.0")
def get_old_threshold() -> float:
"""Use NEW_THRESHOLD constant directly instead."""
return 0.5
# Callers get a warning when they call get_old_threshold()
print(get_old_threshold())
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.
# phmdoctest:skip
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.
old_endpoint("/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
-Wflag 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)
LegacyConfig(timeout=30) # new name — no warning (caller already migrated)
LegacyConfig(time_limit=30) # old name — FutureWarning emitted + remapped
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.
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_mappingas 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
- Warn callers with no forwarding or remapping — use
TargetMode.NOTIFYinstead:
from deprecate import TargetMode, deprecated
@deprecated(target=TargetMode.NOTIFY, deprecated_in="1.0", remove_in="2.0")
def my_func(x: int) -> int:
"""Going away — remove all call sites."""
return x * 2
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.
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.")
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, x: int) -> int:
return x * 2
# Correct: target is on the same class
@deprecated(target=execute, deprecated_in="1.0", remove_in="2.0")
def run(self, x: int) -> int:
pass
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.
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@deprecatedthat setsfn.__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 onFakeOwner. 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.
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).