How would you use a module-level `__getattr__` to keep a moved name importable?
answer
- Keep the old spelling working, but noisily
- A table of old name to new home
- Warn at the caller's line, not the shim's
- Pair the hook with a module __dir__
- warnings.warn with DeprecationWarning, stacklevel=2
basics
~10 sMap 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 sKeep 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 linesimport 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
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.
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__.
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.
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