Skip to content

Advanced🔗

This page covers patterns that go beyond the basics: injecting a deprecation notice into the docstring at import time, supplying a fixed default for a new required argument, testing helpers, deprecating @classmethod and @staticmethod descriptors, and generator functions. For core function patterns see Functions; for async generators see Async.

A callable target with an unreadable signature can be decorated successfully. Forwarding that needs its signature raises the original introspection error at call time, including keyword validation with no args_mapping. An empty call that needs no signature validation can still succeed.

Automatic docstring updates🔗

Set update_docstring=True to inject a deprecation notice directly into the function's docstring at import time. The rendered API reference (Sphinx or MkDocs) always shows the deprecation status alongside the signature, with no manual upkeep. A class wrapped with deprecated_class(update_docstring=True) keeps __doc__ = None when it has no docstring; internal proxy documentation is never injected.

See it live

The Sphinx demo and MkDocs demo show how the injected notice renders in real API docs.

# NEW/FUTURE API — renamed to be more explicit about what it does
def transform(x: int) -> int:
    """New implementation of the function."""
    return x * 2


# doc-example only: forces realistic path in warning; real modules set __module__ automatically
transform.__module__ = "your_module"


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

from deprecate import deprecated


# DEPRECATED API — `process` was the original name before the rename
@deprecated(
    target=transform,
    deprecated_in="1.0",
    remove_in="2.0",
    update_docstring=True,  # Enable automatic docstring updates
)
def process(x: int) -> int:
    """Transforms the input value.

    Args:
        x: Input value

    Returns:
        Result of computation
    """
    pass


# The docstring now includes deprecation information (inserted before "Args:")
print(process.__doc__)
# Output includes:
# .. deprecated:: 1.0
#    Will be removed in 2.0.
#    Use `your_module.transform` instead.
Output: process.__doc__
Transforms the input value.

.. deprecated:: 1.0
   Will be removed in 2.0.
   Use :func:`your_module.transform` instead.

Args:
    x: Input value

Returns:
    Result of computation

For MkDocs projects using mkdocstrings, switch to the admonition output style and register the Griffe extension so the injected notice renders correctly:

from deprecate import deprecated


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


# doc-example only: forces realistic path in warning; real modules set __module__ automatically
transform.__module__ = "your_module"


@deprecated(
    target=transform,
    deprecated_in="1.0",
    remove_in="2.0",
    update_docstring=True,
    docstring_style="mkdocs",  # alias: "markdown"
)
def process(x: int) -> int:
    """Transforms the input value."""
    pass


print(process.__doc__)
# !!! warning "Deprecated in 1.0"
#     Will be removed in 2.0.
#     Use `your_module.transform` instead.
Output: process.__doc__
Transforms the input value.

!!! warning "Deprecated in 1.0"
    Will be removed in 2.0.
    Use `your_module.transform` instead.

Register the extension in mkdocs.yml so mkdocstrings picks up the runtime-injected notice:

# mkdocs.yml
plugins:
  - mkdocstrings:
      handlers:
        python:
          extensions:
            - deprecate.docstring.griffe_ext:RuntimeDocstrings

Injecting new required arguments🔗

When the replacement function gains a new required parameter that the old API never had, use args_extra to inject a fixed default. This forwards calls without breaking existing callers while the deprecation notice tells them to migrate.

from deprecate import deprecated, void


# NEW/FUTURE API — `send_email` adds an explicit `priority` field
def send_email(to: str, subject: str, priority: str) -> str:
    return f"Sent to {to!r}: {subject!r} [{priority}]"


# DEPRECATED API — `notify` was the original name; it had no `priority` concept
@deprecated(
    target=send_email,
    deprecated_in="1.5",
    remove_in="2.0",
    # callers of `notify` never passed `priority`, so inject a sensible default
    args_extra={"priority": "normal"},
)
def notify(to: str, subject: str) -> str:
    """Deprecated — use send_email() with an explicit priority instead."""
    return void(to, subject)


# calling this function will raise a deprecation warning:
#   The `notify` was deprecated since v1.5 in favor of `your_module.send_email`.
#   It will be removed in v2.0.
print(notify("alice@example.com", "Hello"))
Output: notify("alice@example.com", "Hello")
Sent to 'alice@example.com': 'Hello' [normal]

args_extra merges into kwargs after args_mapping is applied. It is used when target is a Callable or TargetMode.ARGS_REMAP (with args_mapping). For TargetMode.NOTIFY, it is not used for forwarding; supplying it also triggers a construction-time UserWarning when the decorator is applied.

Suppressing FutureWarning in test fixtures🔗

In test setup code (fixtures, helpers, factory functions), you often need to call deprecated functions without flooding the output with FutureWarning noise. The first call to a deprecated function still emits its warning (default num_warns=1; subsequent calls are silent) — suppress that specific message session-wide with a scoped filterwarnings entry in pyproject.toml or conftest.py; see Audit Tools → Suppressing warnings in test fixtures for the config snippets.

For assertions, pick the right tool: pytest.warns asserts a warning IS emitted; a filterwarnings ignore entry silences without any assertion; and assert_no_warnings asserts that NO warning of the specified category escapes the block — so refactored code cannot silently regress into calling deprecated APIs. If the code unexpectedly starts emitting a different warning category, that still surfaces.

from deprecate import deprecated, assert_no_warnings, void


# NEW API — creates a client connection dict from host and port
def new_create_client(host: str, port: int = 443) -> dict:
    return {"host": host, "port": port}


# DEPRECATED API — `create_client` replaced by `new_create_client`
@deprecated(target=new_create_client, deprecated_in="1.0", remove_in="2.0")
def create_client(host: str, port: int = 443) -> dict:
    return void(host, port)


def make_test_client() -> dict:
    """Test helper that calls the deprecated API."""
    return create_client("localhost", 8080)  # warns: FutureWarning


# The first call emits one FutureWarning; add a scoped filterwarnings entry to suppress it in fixtures:
client = make_test_client()
print(client)


# For verifying that NEW code does NOT emit warnings, use assert_no_warnings:
with assert_no_warnings(FutureWarning):
    result = new_create_client("example.com")
print(result)
Output: new_create_client("example.com")
{'host': 'localhost', 'port': 8080}
{'host': 'example.com', 'port': 443}

Quick reference for choosing the right testing tool:

Tool Purpose Fails when...
pytest.warns(FutureWarning) Assert warning IS emitted No matching warning raised
assert_no_warnings(FutureWarning) Assert warning is NOT emitted A matching warning IS raised
filterwarnings config (pyproject.toml / conftest.py) Suppress without assertion Never fails (use for fixtures)

Use assert_no_warnings in test assertions to verify that refactored code no longer triggers deprecation notices. Use a scoped filterwarnings ignore entry when fixtures need to call deprecated code silently during setup.

Class methods and static methods🔗

@deprecated works with both @classmethod and @staticmethod in either decorator order — place @deprecated above or below the descriptor decorator and the deprecation warning fires correctly at call time either way.

from deprecate import deprecated


class ApiClient:
    # @deprecated inside @classmethod — conventional order, @deprecated closer to def
    @classmethod
    @deprecated(deprecated_in="1.0", remove_in="2.0")
    def from_url(cls, url: str) -> "ApiClient":
        return cls()

    # @deprecated outside @classmethod — also works; both produce the same descriptor
    @deprecated(deprecated_in="1.2", remove_in="2.0")
    @classmethod
    def from_config(cls, config: dict) -> "ApiClient":
        return cls()

    # Same flexibility with @staticmethod
    @staticmethod
    @deprecated(deprecated_in="0.9", remove_in="1.5")
    def version() -> str:
        return "1.0"

    @deprecated(deprecated_in="1.1", remove_in="2.0")
    @staticmethod
    def build_id() -> str:
        return "legacy"


print(ApiClient.build_id())
Output: ApiClient.build_id()
legacy

Both decorator orders produce classmethod(deprecated_wrapper) or staticmethod(deprecated_wrapper) respectively. The deprecation FutureWarning fires at call time regardless of which order the decorators were applied.

Prefer @classmethod @deprecated (deprecated closer to def)

The inner-first order is the conventional Python style — outer decorators apply last. Follow this pattern for consistency if your team has no existing convention.

Forwarding a static method to another static method in the same class🔗

When renaming a @staticmethod or @classmethod within the same class, pass the new method directly as target=new_method — no .__func__ suffix needed. Inside the class body new_method is still the raw descriptor (not yet bound); pyDeprecate unwraps it automatically.

from deprecate import deprecated, void


class Compute:
    @staticmethod
    def area(radius: float) -> float:
        return 3.14159 * radius**2

    # DEPRECATED — renamed from surface() to area()
    @staticmethod
    @deprecated(target=area, deprecated_in="1.0", remove_in="2.0")
    def surface(radius: float) -> float:
        """Deprecated — use area() instead."""
        return void(radius)


print(Compute.surface(3.0))  # warns: FutureWarning
Output: Compute.surface(3.0)
28.27431

Combine with args_mapping when a parameter was also renamed:

from deprecate import deprecated, void


class Compute:
    @staticmethod
    def area(radius: float) -> float:
        return 3.14159 * radius**2

    @staticmethod
    @deprecated(
        target=area,
        args_mapping={"r": "radius"},
        deprecated_in="1.0",
        remove_in="2.0",
    )
    def surface(r: float = 0.0, radius: float = 0.0) -> float:
        """Deprecated — use area(radius=...) instead."""
        return void(r, radius)


print(Compute.surface(r=3.0))  # warns: FutureWarning
Output: Compute.surface(r=3.0)
28.27431

The same pattern works for @classmethod. Declare both the deprecated and the replacement as classmethods; the other combinations listed below are either rejected or accepted with caveats. The receiver may have any name on either side (cls, klass, mcs): the class the call is bound to, a subclass included, is forwarded into the replacement's own receiver parameter. When the two receivers have different names, a caller keyword passed through **kwargs that matches the replacement's receiver name raises before warning unless args_mapping renames or drops it; "" keeps the name unchanged, so the collision still raises without spending the warning budget. A mismatch surfaces as TypeError, at a point that depends on the direction:

  • a classmethod replacement with a source that has no leading positional parameter raises at decoration time, and so does a static source written @deprecated(...) above @staticmethod, because a static method never receives the class;
  • a later source parameter, an args_extra key or an args_mapping value that carries the replacement's receiver name raises at decoration time: the class is forwarded under that name, so the value would take its place. Rename the parameter, drop the args_extra key, or map the argument to another parameter; an args_mapping entry that renames the source's own receiver onto the replacement's (args_mapping={"klass": "cls"}) is allowed;
  • a classmethod replacement with a plain source, or a static source written @staticmethod above @deprecated(...), decorates fine, because pyDeprecate cannot tell it from a classmethod; the source's first argument is forwarded as the replacement's class, so a call raises before any warning when that argument, or the default it falls back to when the call leaves it out, is not a class, and a class passed there or given as its default is forwarded as the receiver;
  • a classmethod source forwarding to a staticmethod replacement decorates fine and raises TypeError: Failed mapping of `old`, arguments not accepted by target: ['cls'] on the first call.
from deprecate import deprecated, void


class Compute:
    @classmethod
    def area(cls, radius: float) -> float:
        return 3.14159 * radius**2

    # DEPRECATED — renamed from surface() to area()
    @classmethod
    @deprecated(target=area, deprecated_in="1.0", remove_in="2.0")
    def surface(cls, radius: float) -> float:
        """Deprecated — use area() instead."""
        return void(radius)


print(Compute.surface(3.0))  # warns: FutureWarning
Output: Compute.surface(3.0)
28.27431

Deprecating generator functions🔗

Generator functions — any function that contains yield — are fully supported by @deprecated. The wrapper kind follows the deprecated stub, so the stub itself must contain yield: a stub whose body is only return void(...) is a plain function, gets a plain-function wrapper and warns at call time even when its target is a generator. The wrapper is itself a generator function, so inspect.isgeneratorfunction(wrapper) stays True and calling it only creates the generator object: the deprecation warning fires when you first iterate the result (the first next()), not when you call the generator function. The rest of the call path waits for that step too: skip_if is evaluated and the arguments are bound and remapped only then, so a wrong number of arguments or a Failed mapping TypeError raises on the first next(), not at the call.

Keeping the flag matters because frameworks decide how to run a callable from it. pytest, for example, runs a yield fixture with its setup and teardown only when inspect.isgeneratorfunction is True, so a deprecated yield fixture keeps working. The trade-off: a generator that is created but never iterated never warns, and the warning points at the line that iterates, which may be far from the line that called the deprecated function.

from deprecate import deprecated, void


# NEW API — yields sequential integer IDs starting from a given value
def generate_ids(start: int, count: int):
    """Yield `count` sequential IDs starting from `start`."""
    for i in range(count):
        yield start + i


# DEPRECATED API — `iter_ids` replaced by `generate_ids`
@deprecated(target=generate_ids, deprecated_in="0.9", remove_in="1.0")
def iter_ids(start: int, count: int):
    """Deprecated — use generate_ids() instead."""
    void(start, count)
    yield from ()  # `yield` keeps the stub a generator function


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

# The warning fires here — on the first item
print(list(gen))  # warns: FutureWarning
# FutureWarning: The `iter_ids` was deprecated since v0.9 in favor of `generate_ids`.
#                It will be removed in v1.0.
# [10, 11, 12]

All three TargetModes work with generator functions:

TargetMode.NOTIFY — warn and keep the generator body:

from deprecate import deprecated


@deprecated(deprecated_in="0.9", remove_in="1.0")
def run_pipeline(items):
    """This generator is going away; no replacement yet."""
    for item in items:
        yield item.strip()


print(list(run_pipeline(["a ", "b "])))
Output: list(run_pipeline(["a ", "b "]))
['a', 'b']

Warning fires on the first next() (here inside list()), not when the generator object is created. As with any generator, the body executes lazily as the caller iterates, and the warning comes just before the body's first step.

TargetMode.ARGS_REMAP — rename an argument within the same generator:

from deprecate import TargetMode, deprecated


@deprecated(
    target=TargetMode.ARGS_REMAP,
    args_mapping={"n": "count"},
    deprecated_in="0.9",
    remove_in="1.0",
)
def repeat_value(value: int, n: int = 0, count: int = 0):
    """Deprecated argument `n` renamed to `count`."""
    for _ in range(count):
        yield value


print(list(repeat_value(value=1, count=2)))
Output: list(repeat_value(value=1, count=2))
[1, 1]

target=<callable> — forward to a replacement generator:

from deprecate import deprecated, void


# NEW API — yields integers from start (inclusive) to stop (exclusive)
def new_range(start: int, stop: int):
    yield from range(start, stop)


# DEPRECATED API — `old_range` replaced by `new_range`
@deprecated(target=new_range, deprecated_in="0.9", remove_in="1.0")
def old_range(start: int, stop: int):
    void(start, stop)
    yield from ()  # `yield` keeps the stub a generator function


print(list(old_range(1, 4)))
Output: list(old_range(1, 4))
[1, 2, 3]

Warning deduplication and the generator wrapper

The deprecated wrapper for a generator is itself a generator function. On its first step it runs the normal dispatch path once — warning, num_warns budget, argument remapping or forwarding, and an as_exceptions() error — and then delegates to the generator the source or target returns with yield from, so send(), throw(), close() and the return value pass through unchanged. Each iterated generator therefore spends the budget at most once, later steps emit no further warning, and a generator that is never iterated spends nothing. A target that returns a list or another iterable instead of a generator has its items yielded.

See also🔗

  • Use Cases overview — start here for a guided tour of all deprecation patterns
  • Functions — core function deprecation patterns
  • Classes — class, Enum, and dataclass deprecation
  • Properties — @property and @cached_property deprecation
  • Async — async functions and async generators
  • Customization — custom message templates and output streams
  • void() Helper — when and why to use void() in the function body
  • Audit Tools — enforce removal deadlines and detect deprecation chains in CI
  • Troubleshooting — common errors and fixes

Next: Customization — redirect deprecation output to a logger or use a custom message template.