skip to content

How would you use a module-level `__getattr__` to keep a moved name importable?

level: middleimportance: should knowfreq 24%

answer

  1. Keep the old spelling working, but noisily
  2. A table of old name to new home
  3. Warn at the caller's line, not the shim's
  4. Pair the hook with a module __dir__
  5. warnings.warn with DeprecationWarning, stacklevel=2

basics

~10 s

Map each old name to its new home, then in the module's getattr warn with DeprecationWarning and stacklevel=2 and return the object from its new location. Raise AttributeError for anything else.

solid answer

~40 s

Keep a table of `old_name -> (new_module, new_attr)` and write the shim as a module-level `__getattr__`: on a hit, `warnings.warn(msg, DeprecationWarning, stacklevel=2)` so the warning points at the *caller's* line, then `importlib.import_module` the new home and return the attribute; on a miss, `raise AttributeError(f"module {__name__!r} has no attribute {name!r}")`. Pair it with a module-level `__dir__` so `dir()` and REPL completion still show the old names, keep `__all__` describing the *new* surface, and re-declare the real imports under `if TYPE_CHECKING:` so type checkers still resolve them. Decide deliberately whether to bind the redirected object into globals: binding makes the warning fire once per process, not binding warns on every access. Finally, remember `DeprecationWarning` is hidden by default outside `__main__`, so test the shim under `python -W error::DeprecationWarning`.

code

python · 20 lines
python
import warnings
from importlib import import_module

_MOVED = {"diff_legs": ("schedules.compare", "diff_legs")}


def __getattr__(name):
    if name in _MOVED:
        module_name, attr = _MOVED[name]
        warnings.warn(
            f"{__name__}.{name} moved to {module_name}.{attr}",
            DeprecationWarning,
            stacklevel=2,
        )
        return getattr(import_module(module_name), attr)
    raise AttributeError(f"module {__name__!r} has no attribute {name!r}")


def __dir__():
    return sorted(set(globals()) | set(_MOVED))

go deeper

for a junior

Know what a DeprecationWarning in your output is telling you and how to act on it: the message names the new location, and your import is the line to change. Recognising warnings.warn in library source is the takeaway here.

for a middle

Be ready to write the shim from memory: a lookup table, warnings.warn with the right category and stacklevel=2, a lazy import of the new home, an AttributeError fallback, and a matching __dir__.

for a senior

Demonstrate the operational side: escalating warnings to errors in your own test runs, choosing DeprecationWarning versus FutureWarning by audience, and keeping type checkers working with __all__ plus a TYPE_CHECKING block.

for a principal

Own the policy: how long a shim lives, what the release notes must promise, and how you avoid a package whose public surface is a pile of runtime redirects nobody dares delete.

### The problem the shim solves You move a public name - say a schedule-diffing helper - out of a package's `__init__.py` into a submodule, or rename it. Every caller's `from schedules import diff_legs` now breaks. Deleting the old name is honest but hostile; leaving a plain alias is painless but silent, so nobody ever migrates and the alias becomes permanent. A module-level `__getattr__` gives you the third option: the old spelling keeps working *and* announces that it is on the way out. ### The shape of the shim ```python import warnings from importlib import import_module _MOVED = {"diff_legs": ("schedules.compare", "diff_legs")} def __getattr__(name): if name in _MOVED: module_name, attr = _MOVED[name] warnings.warn( f"{__name__}.{name} moved to {module_name}.{attr}", DeprecationWarning, stacklevel=2, ) return getattr(import_module(module_name), attr) raise AttributeError(f"module {__name__!r} has no attribute {name!r}") def __dir__(): return sorted(set(globals()) | set(_MOVED)) ``` Every line is load-bearing. **The table, not a chain of `if`s.** A dict of old name to new location keeps the shim reviewable and makes it obvious what is left to remove when the deprecation window closes. **`stacklevel=2`.** The default, `stacklevel=1`, attributes the warning to the line inside the shim - useless to the person who has to fix it. Level 2 attributes it to the caller's own line, which is what turns the warning into an actionable diff. If the shim grows a helper function in between, the level has to grow with it. **`DeprecationWarning`, via `warnings.warn`.** Not `print`, not a logger. The warnings system is filterable, is what `-W error::DeprecationWarning` escalates, and is what a test suite can assert on. If the audience is application developers rather than library authors, `FutureWarning` is the category that is shown by default and is the right choice for a warning end users must see. **The import inside the hook.** Importing the new home lazily keeps the old module from eagerly pulling in a submodule it no longer needs, and it sidesteps the circular-import knot that motivated the move in half of all real cases. **The `AttributeError` branch.** Without it the shim answers every probe, `hasattr()` becomes useless, and speculative lookups for `__path__` or `__all__` get a nonsense value back. Use the interpreter's own message text so tracebacks look native. **`__dir__`.** `dir()` on a module reports the keys of its `__dict__`, so a name that only the hook can produce is invisible to REPL completion and to introspection-driven tooling. The paired `__dir__` puts the moved names back into that list. ### To cache or not to cache The hook is consulted on every miss, so unless it binds the value into the module's globals it runs again on the next access. For a deprecation shim, *not* binding is usually right: you want the warning each time a distinct call site is exercised, and the warnings filter already collapses repeats per location. Binding turns the whole thing into a once-per-process warning, which is fine when the goal is only to nudge, and is the right choice when the hook does real work rather than just a redirect. ### Making sure anyone actually sees it `DeprecationWarning` is ignored by default except when it is triggered in `__main__` - the compromise PEP 565 landed in 3.7 after years of the warning being invisible to the very people who needed it. In practice that means library users running a real application see nothing. So: document the move in the release notes, run your own test suite with warnings escalated to errors, and consider `FutureWarning` when the message is aimed at application authors rather than library maintainers. ### Keeping static tooling honest Type checkers, IDEs and documentation builders read source. They cannot see a name that only exists at runtime, which is why a bare shim makes editors report the old name as undefined and can make the *new* name look unused. Two habits fix it: keep `__all__` describing the surface you actually support now, and put the genuine imports under `if TYPE_CHECKING:` so the static view resolves while the runtime stays lazy. ### Retiring the shim A shim is a dated promise, not furniture. Record the release in which the old name goes away, keep the `_MOVED` table small enough to delete in one commit, and actually delete it - a shim nobody removes is just an alias with extra steps.

  • Why does the shim pass `stacklevel=2` to `warnings.warn`?
    So the warning is attributed to the caller's line rather than to the line inside the shim. The default of 1 blames the shim itself, which tells the person reading the output nothing about which of their imports to change. If you factor the warning into a helper the shim calls, the level must increase to match the extra frame.
  • Users report they never see the warning. Why, and what do you do?
    `DeprecationWarning` is ignored by default unless it is triggered in `__main__`, so a library user running an application sees nothing. Escalate it in your own test runs with `python -W error::DeprecationWarning`, document the move in the release notes, and use `FutureWarning` when the message is genuinely aimed at application authors, since that category is shown by default.
  • Should the shim bind the redirected object into the module's globals?
    It is a deliberate trade. Binding means the hook runs once and the warning fires once per process; not binding means the hook runs on every access, so distinct call sites each get warned. Redirect shims usually skip the binding, because the warning is the point. A hook that does expensive work rather than a lookup should bind, so the work happens once.

saying these in an interview costs you the question

  • Leaves the old name as a plain alias, so nothing ever warns
  • Uses print or a logger instead of warnings.warn
  • Omits stacklevel, so the warning blames the shim's own line
  • Forgets the AttributeError branch, making hasattr true for everything
  • Assumes users see DeprecationWarning by default
  • Never updates __dir__ or __all__, so tooling loses the names

context