forked from briancurtin/deprecation
-
Notifications
You must be signed in to change notification settings - Fork 0
/
deprecation.py
290 lines (244 loc) · 12.6 KB
/
deprecation.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import collections
import functools
import textwrap
import warnings
from packaging import version
from datetime import date
__version__ = "2.1.0"
# This is mostly here so automodule docs are ordered more ideally.
__all__ = ["deprecated", "message_location", "fail_if_not_removed",
"DeprecatedWarning", "UnsupportedWarning"]
#: Location where the details are added to a deprecated docstring
#:
#: When set to ``"bottom"``, the details are appended to the end.
#: When set to ``"top"``, the details are inserted between the
#: summary line and docstring contents.
message_location = "bottom"
class DeprecatedWarning(DeprecationWarning):
"""A warning class for deprecated methods
This is a specialization of the built-in :class:`DeprecationWarning`,
adding parameters that allow us to get information into the __str__
that ends up being sent through the :mod:`warnings` system.
The attributes aren't able to be retrieved after the warning gets
raised and passed through the system as only the class--not the
instance--and message are what gets preserved.
:param function: The function being deprecated.
:param deprecated_in: The version that ``function`` is deprecated in
:param removed_in: The version or :class:`datetime.date` specifying
when ``function`` gets removed.
:param details: Optional details about the deprecation. Most often
this will include directions on what to use instead
of the now deprecated code.
"""
def __init__(self, function, deprecated_in, removed_in, details=""):
# NOTE: The docstring only works for this class if it appears up
# near the class name, not here inside __init__. I think it has
# to do with being an exception class.
self.function = function
self.deprecated_in = deprecated_in
self.removed_in = removed_in
self.details = details
super(DeprecatedWarning, self).__init__(function, deprecated_in,
removed_in, details)
def __str__(self):
# Use a defaultdict to give us the empty string
# when a part isn't included.
parts = collections.defaultdict(str)
parts["function"] = self.function
if self.deprecated_in:
parts["deprecated"] = " as of %s" % self.deprecated_in
if self.removed_in:
parts["removed"] = " and will be removed {} {}".format("on" if isinstance(self.removed_in, date) else "in",
self.removed_in)
if any([self.deprecated_in, self.removed_in, self.details]):
parts["period"] = "."
if self.details:
parts["details"] = " %s" % self.details
return ("%(function)s is deprecated%(deprecated)s%(removed)s"
"%(period)s%(details)s" % (parts))
class UnsupportedWarning(DeprecatedWarning):
"""A warning class for methods to be removed
This is a subclass of :class:`~deprecation.DeprecatedWarning` and is used
to output a proper message about a function being unsupported.
Additionally, the :func:`~deprecation.fail_if_not_removed` decorator
will handle this warning and cause any tests to fail if the system
under test uses code that raises this warning.
"""
def __str__(self):
parts = collections.defaultdict(str)
parts["function"] = self.function
parts["removed"] = self.removed_in
if self.details:
parts["details"] = " %s" % self.details
return ("%(function)s is unsupported as of %(removed)s."
"%(details)s" % (parts))
def deprecated(deprecated_in=None, removed_in=None, current_version=None,
details=""):
"""Decorate a function to signify its deprecation
This function wraps a method that will soon be removed and does two things:
* The docstring of the method will be modified to include a notice
about deprecation, e.g., "Deprecated since 0.9.11. Use foo instead."
* Raises a :class:`~deprecation.DeprecatedWarning`
via the :mod:`warnings` module, which is a subclass of the built-in
:class:`DeprecationWarning`. Note that built-in
:class:`DeprecationWarning`s are ignored by default, so for users
to be informed of said warnings they will need to enable them--see
the :mod:`warnings` module documentation for more details.
:param deprecated_in: The version at which the decorated method is
considered deprecated. This will usually be the
next version to be released when the decorator is
added. The default is **None**, which effectively
means immediate deprecation. If this is not
specified, then the `removed_in` and
`current_version` arguments are ignored.
:param removed_in: The version or :class:`datetime.date` when the decorated
method will be removed. The default is **None**,
specifying that the function is not currently planned
to be removed.
Note: This parameter cannot be set to a value if
`deprecated_in=None`.
:param current_version: The source of version information for the
currently running code. This will usually be
a `__version__` attribute on your library.
The default is `None`.
When `current_version=None` the automation to
determine if the wrapped function is actually
in a period of deprecation or time for removal
does not work, causing a
:class:`~deprecation.DeprecatedWarning`
to be raised in all cases.
:param details: Extra details to be added to the method docstring and
warning. For example, the details may point users to
a replacement method, such as "Use the foo_bar
method instead". By default there are no details.
"""
# You can't just jump to removal. It's weird, unfair, and also makes
# building up the docstring weird.
if deprecated_in is None and removed_in is not None:
raise TypeError("Cannot set removed_in to a value "
"without also setting deprecated_in")
# Only warn when it's appropriate. There may be cases when it makes sense
# to add this decorator before a formal deprecation period begins.
# In CPython, PendingDeprecatedWarning gets used in that period,
# so perhaps mimick that at some point.
is_deprecated = False
is_unsupported = False
# StrictVersion won't take a None or a "", so make whatever goes to it
# is at least *something*. Compare versions only if removed_in is not
# of type datetime.date
if isinstance(removed_in, date):
if date.today() >= removed_in:
is_unsupported = True
else:
is_deprecated = True
elif current_version:
current_version = version.parse(current_version)
if (removed_in
and current_version >= version.parse(removed_in)):
is_unsupported = True
elif (deprecated_in
and current_version >= version.parse(deprecated_in)):
is_deprecated = True
else:
# If we can't actually calculate that we're in a period of
# deprecation...well, they used the decorator, so it's deprecated.
# This will cover the case of someone just using
# @deprecated("1.0") without the other advantages.
is_deprecated = True
should_warn = any([is_deprecated, is_unsupported])
def _function_wrapper(function):
if should_warn:
# Everything *should* have a docstring, but just in case...
existing_docstring = function.__doc__ or ""
# The various parts of this decorator being optional makes for
# a number of ways the deprecation notice could go. The following
# makes for a nicely constructed sentence with or without any
# of the parts.
# If removed_in is a date, use "removed on"
# If removed_in is a version, use "removed in"
parts = {
"deprecated_in":
" %s" % deprecated_in if deprecated_in else "",
"removed_in":
"\n This will be removed {} {}.".format("on" if isinstance(removed_in, date) else "in",
removed_in) if removed_in else "",
"details":
" %s" % details if details else ""}
deprecation_note = (".. deprecated::{deprecated_in}"
"{removed_in}{details}".format(**parts))
# default location for insertion of deprecation note
loc = 1
# split docstring at first occurrence of newline
string_list = existing_docstring.split("\n", 1)
if len(string_list) > 1:
# With a multi-line docstring, when we modify
# existing_docstring to add our deprecation_note,
# if we're not careful we'll interfere with the
# indentation levels of the contents below the
# first line, or as PEP 257 calls it, the summary
# line. Since the summary line can start on the
# same line as the """, dedenting the whole thing
# won't help. Split the summary and contents up,
# dedent the contents independently, then join
# summary, dedent'ed contents, and our
# deprecation_note.
# in-place dedent docstring content
string_list[1] = textwrap.dedent(string_list[1])
# we need another newline
string_list.insert(loc, "\n")
# change the message_location if we add to end of docstring
# do this always if not "top"
if message_location != "top":
loc = 3
# insert deprecation note and dual newline
string_list.insert(loc, deprecation_note)
string_list.insert(loc, "\n\n")
function.__doc__ = "".join(string_list)
@functools.wraps(function)
def _inner(*args, **kwargs):
if should_warn:
if is_unsupported:
cls = UnsupportedWarning
else:
cls = DeprecatedWarning
the_warning = cls(function.__name__, deprecated_in,
removed_in, details)
warnings.warn(the_warning, category=DeprecationWarning,
stacklevel=2)
return function(*args, **kwargs)
return _inner
return _function_wrapper
def fail_if_not_removed(method):
"""Decorate a test method to track removal of deprecated code
This decorator catches :class:`~deprecation.UnsupportedWarning`
warnings that occur during testing and causes unittests to fail,
making it easier to keep track of when code should be removed.
:raises: :class:`AssertionError` if an
:class:`~deprecation.UnsupportedWarning`
is raised while running the test method.
"""
# NOTE(briancurtin): Unless this is named test_inner, nose won't work
# properly. See Issue #32.
@functools.wraps(method)
def test_inner(*args, **kwargs):
with warnings.catch_warnings(record=True) as caught_warnings:
warnings.simplefilter("always")
rv = method(*args, **kwargs)
for warning in caught_warnings:
if warning.category == UnsupportedWarning:
raise AssertionError(
("%s uses a function that should be removed: %s" %
(method, str(warning.message))))
return rv
return test_inner