Skip to content

← Failure by Design step 13 of 18

Medium Framework

Deprecating public API: @warnings.deprecated

Removing a public symbol without a deprecation cycle breaks other people’s builds with no migration path. Adding a note to the docstring instead means nobody finds out until you delete it. The middle path is a machine-readable deprecation, and since Python 3.13 the standard spelling is @warnings.deprecated (PEP 702).

from warnings import deprecated

@deprecated("use Client.fetch() instead; removal in 4.0")
def fetch_sync(url: str) -> bytes: ...

What that actually does:

  • On a function, emits a DeprecationWarning when it is called.
  • On a class, emits one on instantiation and on subclass creation — both, because both are ways of depending on it.
  • Sets __deprecated__ to the message, so tooling can read it without parsing your source.
  • category=None makes it static-only: type checkers report it, nothing happens at runtime. That is the right setting for a hot path you do not want to slow down.
  • Place it after @overload, not before, when you are deprecating one overload.
  • Available from typing_extensions for 3.12 and earlier.

And the part everybody trips over: DeprecationWarning is hidden by default outside __main__. Your users will not see it unless they ask. Run python -W error::DeprecationWarning in CI — yours and, if you can persuade them, theirs.

Deprecation is a process, not a value

A pure function cannot be a deprecation cycle. What it can be is the check a type checker runs: given a module’s symbol table and a list of places those symbols are used, produce the diagnostics. Building it teaches you the shape of the check — and the shape is why __deprecated__ exists as an attribute rather than a docstring convention.

def deprecation_report(
    module_symbols: Mapping[str, str | None],
    usages: Sequence[tuple[str, str]],
) -> list[str]:
  • module_symbols maps every public symbol name to its deprecation message, or to None when the symbol is fine. Mirroring __deprecated__: present means deprecated, absent means not.
  • usages is a sequence of (caller, symbol) tuples — one per site where a symbol is referenced.

Rules:

  1. A symbol in usages that is not a key of module_symbols is not a deprecation finding, it is a bug in the caller list. Raise KeyError(symbol) as soon as you meet it.
  2. Skip symbols whose entry is None.
  3. Emit one diagnostic per distinct (caller, symbol) pair, however many times that pair appears. Ten calls to the same deprecated helper from the same module is one problem to fix, not ten.
  4. Sort the output by (caller, symbol). Unstable diagnostic ordering makes a CI diff unreadable.

The format mirrors mypy’s own deprecated error code exactly, including the two spaces before the bracket:

{caller}: error: {symbol} is deprecated: {message}  [deprecated]

The probe

def solve(
    module_symbols: Mapping[str, str | None],
    usages: Sequence[tuple[str, str]],
) -> dict[str, object]:

Call deprecation_report and convert the KeyError into a reportable value:

{"outcome": "ok", "symbol": None, "diagnostics": [...]}
{"outcome": "unknown-symbol", "symbol": <the offending name>, "diagnostics": []}

Typing notes

Mapping[str, str | None] is the whole design in one annotation: None is a value meaning “not deprecated”, so if module_symbols[symbol] is not None is the test, and if module_symbols[symbol] is a bug waiting for the day someone deprecates something with an empty message.

usages is a sequence of tuples, not of lists — tuple[str, str] is a fixed-arity heterogeneous record and mypy holds you to the arity. Unpacking it in the loop header (for caller, symbol in usages) is what makes both names str without a single annotation.