| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251 |
- # Human friendly input/output in Python.
- #
- # Author: Peter Odding <peter@peterodding.com>
- # Last Change: March 2, 2020
- # URL: https://humanfriendly.readthedocs.io
- """
- Support for deprecation warnings when importing names from old locations.
- When software evolves, things tend to move around. This is usually detrimental
- to backwards compatibility (in Python this primarily manifests itself as
- :exc:`~exceptions.ImportError` exceptions).
- While backwards compatibility is very important, it should not get in the way
- of progress. It would be great to have the agility to move things around
- without breaking backwards compatibility.
- This is where the :mod:`humanfriendly.deprecation` module comes in: It enables
- the definition of backwards compatible aliases that emit a deprecation warning
- when they are accessed.
- The way it works is that it wraps the original module in an :class:`DeprecationProxy`
- object that defines a :func:`~DeprecationProxy.__getattr__()` special method to
- override attribute access of the module.
- """
- # Standard library modules.
- import collections
- import functools
- import importlib
- import inspect
- import sys
- import types
- import warnings
- # Modules included in our package.
- from humanfriendly.text import format
- # Registry of known aliases (used by humanfriendly.sphinx).
- REGISTRY = collections.defaultdict(dict)
- # Public identifiers that require documentation.
- __all__ = ("DeprecationProxy", "define_aliases", "deprecated_args", "get_aliases", "is_method")
- def define_aliases(module_name, **aliases):
- """
- Update a module with backwards compatible aliases.
- :param module_name: The ``__name__`` of the module (a string).
- :param aliases: Each keyword argument defines an alias. The values
- are expected to be "dotted paths" (strings).
- The behavior of this function depends on whether the Sphinx documentation
- generator is active, because the use of :class:`DeprecationProxy` to shadow the
- real module in :data:`sys.modules` has the unintended side effect of
- breaking autodoc support for ``:data:`` members (module variables).
- To avoid breaking Sphinx the proxy object is omitted and instead the
- aliased names are injected into the original module namespace, to make sure
- that imports can be satisfied when the documentation is being rendered.
- If you run into cyclic dependencies caused by :func:`define_aliases()` when
- running Sphinx, you can try moving the call to :func:`define_aliases()` to
- the bottom of the Python module you're working on.
- """
- module = sys.modules[module_name]
- proxy = DeprecationProxy(module, aliases)
- # Populate the registry of aliases.
- for name, target in aliases.items():
- REGISTRY[module.__name__][name] = target
- # Avoid confusing Sphinx.
- if "sphinx" in sys.modules:
- for name, target in aliases.items():
- setattr(module, name, proxy.resolve(target))
- else:
- # Install a proxy object to raise DeprecationWarning.
- sys.modules[module_name] = proxy
- def get_aliases(module_name):
- """
- Get the aliases defined by a module.
- :param module_name: The ``__name__`` of the module (a string).
- :returns: A dictionary with string keys and values:
- 1. Each key gives the name of an alias
- created for backwards compatibility.
- 2. Each value gives the dotted path of
- the proper location of the identifier.
- An empty dictionary is returned for modules that
- don't define any backwards compatible aliases.
- """
- return REGISTRY.get(module_name, {})
- def deprecated_args(*names):
- """
- Deprecate positional arguments without dropping backwards compatibility.
- :param names:
- The positional arguments to :func:`deprecated_args()` give the names of
- the positional arguments that the to-be-decorated function should warn
- about being deprecated and translate to keyword arguments.
- :returns: A decorator function specialized to `names`.
- The :func:`deprecated_args()` decorator function was created to make it
- easy to switch from positional arguments to keyword arguments [#]_ while
- preserving backwards compatibility [#]_ and informing call sites
- about the change.
- .. [#] Increased flexibility is the main reason why I find myself switching
- from positional arguments to (optional) keyword arguments as my code
- evolves to support more use cases.
- .. [#] In my experience positional argument order implicitly becomes part
- of API compatibility whether intended or not. While this makes sense
- for functions that over time adopt more and more optional arguments,
- at a certain point it becomes an inconvenience to code maintenance.
- Here's an example of how to use the decorator::
- @deprecated_args('text')
- def report_choice(**options):
- print(options['text'])
- When the decorated function is called with positional arguments
- a deprecation warning is given::
- >>> report_choice('this will give a deprecation warning')
- DeprecationWarning: report_choice has deprecated positional arguments, please switch to keyword arguments
- this will give a deprecation warning
- But when the function is called with keyword arguments no deprecation
- warning is emitted::
- >>> report_choice(text='this will not give a deprecation warning')
- this will not give a deprecation warning
- """
- def decorator(function):
- def translate(args, kw):
- # Raise TypeError when too many positional arguments are passed to the decorated function.
- if len(args) > len(names):
- raise TypeError(
- format(
- "{name} expected at most {limit} arguments, got {count}",
- name=function.__name__,
- limit=len(names),
- count=len(args),
- )
- )
- # Emit a deprecation warning when positional arguments are used.
- if args:
- warnings.warn(
- format(
- "{name} has deprecated positional arguments, please switch to keyword arguments",
- name=function.__name__,
- ),
- category=DeprecationWarning,
- stacklevel=3,
- )
- # Translate positional arguments to keyword arguments.
- for name, value in zip(names, args):
- kw[name] = value
- if is_method(function):
- @functools.wraps(function)
- def wrapper(*args, **kw):
- """Wrapper for instance methods."""
- args = list(args)
- self = args.pop(0)
- translate(args, kw)
- return function(self, **kw)
- else:
- @functools.wraps(function)
- def wrapper(*args, **kw):
- """Wrapper for module level functions."""
- translate(args, kw)
- return function(**kw)
- return wrapper
- return decorator
- def is_method(function):
- """Check if the expected usage of the given function is as an instance method."""
- try:
- # Python 3.3 and newer.
- signature = inspect.signature(function)
- return "self" in signature.parameters
- except AttributeError:
- # Python 3.2 and older.
- metadata = inspect.getargspec(function)
- return "self" in metadata.args
- class DeprecationProxy(types.ModuleType):
- """Emit deprecation warnings for imports that should be updated."""
- def __init__(self, module, aliases):
- """
- Initialize an :class:`DeprecationProxy` object.
- :param module: The original module object.
- :param aliases: A dictionary of aliases.
- """
- # Initialize our superclass.
- super(DeprecationProxy, self).__init__(name=module.__name__)
- # Store initializer arguments.
- self.module = module
- self.aliases = aliases
- def __getattr__(self, name):
- """
- Override module attribute lookup.
- :param name: The name to look up (a string).
- :returns: The attribute value.
- """
- # Check if the given name is an alias.
- target = self.aliases.get(name)
- if target is not None:
- # Emit the deprecation warning.
- warnings.warn(
- format("%s.%s was moved to %s, please update your imports", self.module.__name__, name, target),
- category=DeprecationWarning,
- stacklevel=2,
- )
- # Resolve the dotted path.
- return self.resolve(target)
- # Look up the name in the original module namespace.
- value = getattr(self.module, name, None)
- if value is not None:
- return value
- # Fall back to the default behavior.
- raise AttributeError(format("module '%s' has no attribute '%s'", self.module.__name__, name))
- def resolve(self, target):
- """
- Look up the target of an alias.
- :param target: The fully qualified dotted path (a string).
- :returns: The value of the given target.
- """
- module_name, _, member = target.rpartition(".")
- module = importlib.import_module(module_name)
- return getattr(module, member)
|