comparison env/lib/python3.7/site-packages/humanfriendly/deprecation.py @ 0:26e78fe6e8c4 draft

"planemo upload commit c699937486c35866861690329de38ec1a5d9f783"
author shellac
date Sat, 02 May 2020 07:14:21 -0400
parents
children
comparison
equal deleted inserted replaced
-1:000000000000 0:26e78fe6e8c4
1 # Human friendly input/output in Python.
2 #
3 # Author: Peter Odding <peter@peterodding.com>
4 # Last Change: March 2, 2020
5 # URL: https://humanfriendly.readthedocs.io
6
7 """
8 Support for deprecation warnings when importing names from old locations.
9
10 When software evolves, things tend to move around. This is usually detrimental
11 to backwards compatibility (in Python this primarily manifests itself as
12 :exc:`~exceptions.ImportError` exceptions).
13
14 While backwards compatibility is very important, it should not get in the way
15 of progress. It would be great to have the agility to move things around
16 without breaking backwards compatibility.
17
18 This is where the :mod:`humanfriendly.deprecation` module comes in: It enables
19 the definition of backwards compatible aliases that emit a deprecation warning
20 when they are accessed.
21
22 The way it works is that it wraps the original module in an :class:`DeprecationProxy`
23 object that defines a :func:`~DeprecationProxy.__getattr__()` special method to
24 override attribute access of the module.
25 """
26
27 # Standard library modules.
28 import collections
29 import functools
30 import importlib
31 import inspect
32 import sys
33 import types
34 import warnings
35
36 # Modules included in our package.
37 from humanfriendly.text import format
38
39 # Registry of known aliases (used by humanfriendly.sphinx).
40 REGISTRY = collections.defaultdict(dict)
41
42 # Public identifiers that require documentation.
43 __all__ = ("DeprecationProxy", "define_aliases", "deprecated_args", "get_aliases", "is_method")
44
45
46 def define_aliases(module_name, **aliases):
47 """
48 Update a module with backwards compatible aliases.
49
50 :param module_name: The ``__name__`` of the module (a string).
51 :param aliases: Each keyword argument defines an alias. The values
52 are expected to be "dotted paths" (strings).
53
54 The behavior of this function depends on whether the Sphinx documentation
55 generator is active, because the use of :class:`DeprecationProxy` to shadow the
56 real module in :data:`sys.modules` has the unintended side effect of
57 breaking autodoc support for ``:data:`` members (module variables).
58
59 To avoid breaking Sphinx the proxy object is omitted and instead the
60 aliased names are injected into the original module namespace, to make sure
61 that imports can be satisfied when the documentation is being rendered.
62
63 If you run into cyclic dependencies caused by :func:`define_aliases()` when
64 running Sphinx, you can try moving the call to :func:`define_aliases()` to
65 the bottom of the Python module you're working on.
66 """
67 module = sys.modules[module_name]
68 proxy = DeprecationProxy(module, aliases)
69 # Populate the registry of aliases.
70 for name, target in aliases.items():
71 REGISTRY[module.__name__][name] = target
72 # Avoid confusing Sphinx.
73 if "sphinx" in sys.modules:
74 for name, target in aliases.items():
75 setattr(module, name, proxy.resolve(target))
76 else:
77 # Install a proxy object to raise DeprecationWarning.
78 sys.modules[module_name] = proxy
79
80
81 def get_aliases(module_name):
82 """
83 Get the aliases defined by a module.
84
85 :param module_name: The ``__name__`` of the module (a string).
86 :returns: A dictionary with string keys and values:
87
88 1. Each key gives the name of an alias
89 created for backwards compatibility.
90
91 2. Each value gives the dotted path of
92 the proper location of the identifier.
93
94 An empty dictionary is returned for modules that
95 don't define any backwards compatible aliases.
96 """
97 return REGISTRY.get(module_name, {})
98
99
100 def deprecated_args(*names):
101 """
102 Deprecate positional arguments without dropping backwards compatibility.
103
104 :param names:
105
106 The positional arguments to :func:`deprecated_args()` give the names of
107 the positional arguments that the to-be-decorated function should warn
108 about being deprecated and translate to keyword arguments.
109
110 :returns: A decorator function specialized to `names`.
111
112 The :func:`deprecated_args()` decorator function was created to make it
113 easy to switch from positional arguments to keyword arguments [#]_ while
114 preserving backwards compatibility [#]_ and informing call sites
115 about the change.
116
117 .. [#] Increased flexibility is the main reason why I find myself switching
118 from positional arguments to (optional) keyword arguments as my code
119 evolves to support more use cases.
120
121 .. [#] In my experience positional argument order implicitly becomes part
122 of API compatibility whether intended or not. While this makes sense
123 for functions that over time adopt more and more optional arguments,
124 at a certain point it becomes an inconvenience to code maintenance.
125
126 Here's an example of how to use the decorator::
127
128 @deprecated_args('text')
129 def report_choice(**options):
130 print(options['text'])
131
132 When the decorated function is called with positional arguments
133 a deprecation warning is given::
134
135 >>> report_choice('this will give a deprecation warning')
136 DeprecationWarning: report_choice has deprecated positional arguments, please switch to keyword arguments
137 this will give a deprecation warning
138
139 But when the function is called with keyword arguments no deprecation
140 warning is emitted::
141
142 >>> report_choice(text='this will not give a deprecation warning')
143 this will not give a deprecation warning
144 """
145 def decorator(function):
146 def translate(args, kw):
147 # Raise TypeError when too many positional arguments are passed to the decorated function.
148 if len(args) > len(names):
149 raise TypeError(
150 format(
151 "{name} expected at most {limit} arguments, got {count}",
152 name=function.__name__,
153 limit=len(names),
154 count=len(args),
155 )
156 )
157 # Emit a deprecation warning when positional arguments are used.
158 if args:
159 warnings.warn(
160 format(
161 "{name} has deprecated positional arguments, please switch to keyword arguments",
162 name=function.__name__,
163 ),
164 category=DeprecationWarning,
165 stacklevel=3,
166 )
167 # Translate positional arguments to keyword arguments.
168 for name, value in zip(names, args):
169 kw[name] = value
170 if is_method(function):
171 @functools.wraps(function)
172 def wrapper(*args, **kw):
173 """Wrapper for instance methods."""
174 args = list(args)
175 self = args.pop(0)
176 translate(args, kw)
177 return function(self, **kw)
178 else:
179 @functools.wraps(function)
180 def wrapper(*args, **kw):
181 """Wrapper for module level functions."""
182 translate(args, kw)
183 return function(**kw)
184 return wrapper
185 return decorator
186
187
188 def is_method(function):
189 """Check if the expected usage of the given function is as an instance method."""
190 try:
191 # Python 3.3 and newer.
192 signature = inspect.signature(function)
193 return "self" in signature.parameters
194 except AttributeError:
195 # Python 3.2 and older.
196 metadata = inspect.getargspec(function)
197 return "self" in metadata.args
198
199
200 class DeprecationProxy(types.ModuleType):
201
202 """Emit deprecation warnings for imports that should be updated."""
203
204 def __init__(self, module, aliases):
205 """
206 Initialize an :class:`DeprecationProxy` object.
207
208 :param module: The original module object.
209 :param aliases: A dictionary of aliases.
210 """
211 # Initialize our superclass.
212 super(DeprecationProxy, self).__init__(name=module.__name__)
213 # Store initializer arguments.
214 self.module = module
215 self.aliases = aliases
216
217 def __getattr__(self, name):
218 """
219 Override module attribute lookup.
220
221 :param name: The name to look up (a string).
222 :returns: The attribute value.
223 """
224 # Check if the given name is an alias.
225 target = self.aliases.get(name)
226 if target is not None:
227 # Emit the deprecation warning.
228 warnings.warn(
229 format("%s.%s was moved to %s, please update your imports", self.module.__name__, name, target),
230 category=DeprecationWarning,
231 stacklevel=2,
232 )
233 # Resolve the dotted path.
234 return self.resolve(target)
235 # Look up the name in the original module namespace.
236 value = getattr(self.module, name, None)
237 if value is not None:
238 return value
239 # Fall back to the default behavior.
240 raise AttributeError(format("module '%s' has no attribute '%s'", self.module.__name__, name))
241
242 def resolve(self, target):
243 """
244 Look up the target of an alias.
245
246 :param target: The fully qualified dotted path (a string).
247 :returns: The value of the given target.
248 """
249 module_name, _, member = target.rpartition(".")
250 module = importlib.import_module(module_name)
251 return getattr(module, member)