From fc0304e2a6933da32a347c9fd7aebbe5e106a7a7 Mon Sep 17 00:00:00 2001 From: Joshua Harlow Date: Mon, 21 Dec 2015 09:07:08 -0800 Subject: [PATCH] Add 'removed_class' class decorator To augment the problems with @remove on classes, see bug #1520397 and bug #1500851 introduce a class decorator that is specifically made for removing existing classes (and it appears to work correctly even under inheritance). Related-Bug: #1520397 Related-Bug: #1500851 Change-Id: I91adbdacc9fc77511d3f0bfb66d558269c49f885 --- debtcollector/_utils.py | 13 +++++++++ debtcollector/moves.py | 6 ++--- debtcollector/removals.py | 36 ++++++++++++++++++++++++- debtcollector/renames.py | 2 +- debtcollector/tests/test_deprecation.py | 30 +++++++++++++++++++++ doc/source/examples.rst | 4 +-- 6 files changed, 84 insertions(+), 7 deletions(-) diff --git a/debtcollector/_utils.py b/debtcollector/_utils.py index e4af11f..640a9b0 100644 --- a/debtcollector/_utils.py +++ b/debtcollector/_utils.py @@ -14,6 +14,7 @@ # License for the specific language governing permissions and limitations # under the License. +import functools import inspect import types import warnings @@ -88,6 +89,18 @@ def generate_message(prefix, postfix=None, message=None, return ''.join(message_components) +def get_assigned(decorator): + """Helper to fix/workaround https://bugs.python.org/issue3445""" + if six.PY3: + return functools.WRAPPER_ASSIGNMENTS + else: + assigned = [] + for attr_name in functools.WRAPPER_ASSIGNMENTS: + if hasattr(decorator, attr_name): + assigned.append(attr_name) + return tuple(assigned) + + def get_class_name(obj, fully_qualified=True): """Get class name for object. diff --git a/debtcollector/moves.py b/debtcollector/moves.py index e54ab36..13b0408 100644 --- a/debtcollector/moves.py +++ b/debtcollector/moves.py @@ -36,7 +36,7 @@ def _moved_decorator(kind, new_attribute_name, message=None, if attr_postfix: old_attribute_name += attr_postfix - @six.wraps(f) + @six.wraps(f, assigned=_utils.get_assigned(f)) def wrapper(self, *args, **kwargs): base_name = _utils.get_class_name(self, fully_qualified=False) if fully_qualified: @@ -75,7 +75,7 @@ def moved_function(new_func, old_func_name, old_module_name, message=message, version=version, removal_version=removal_version) - @six.wraps(new_func) + @six.wraps(new_func, assigned=_utils.get_assigned(new_func)) def old_new_func(*args, **kwargs): _utils.deprecation(out_message, stacklevel=stacklevel, category=category) @@ -182,7 +182,7 @@ def moved_class(new_class, old_class_name, old_module_name, def decorator(f): - @six.wraps(f, assigned=("__name__", "__doc__")) + @six.wraps(f, assigned=_utils.get_assigned(f)) def wrapper(self, *args, **kwargs): _utils.deprecation(out_message, stacklevel=stacklevel, category=category) diff --git a/debtcollector/removals.py b/debtcollector/removals.py index 5294a45..aa416ed 100644 --- a/debtcollector/removals.py +++ b/debtcollector/removals.py @@ -167,7 +167,8 @@ def remove(f=None, message=None, version=None, removal_version=None, Due to limitations of the wrapt library (and python) itself, if this is applied to subclasses of metaclasses then it likely will not work as expected. More information can be found at bug #1520397 to see if - this situation affects your usage of this *universal* decorator. + this situation affects your usage of this *universal* decorator, for + this specific scenario please use :py:func:`.removed_class` instead. :param str message: A message to include in the deprecation warning :param str version: Specify what version the removed function is present in @@ -262,6 +263,39 @@ def removed_kwarg(old_name, message=None, return wrapper +def removed_class(cls_name, replacement=None, message=None, + version=None, removal_version=None, stacklevel=3, + category=None): + """Decorates a class to denote that it will be removed at some point.""" + + def _wrap_it(old_init, out_message): + + @six.wraps(old_init, assigned=_utils.get_assigned(old_init)) + def new_init(self, *args, **kwargs): + _utils.deprecation(out_message, stacklevel=stacklevel, + category=category) + return old_init(self, *args, **kwargs) + + return new_init + + def _check_it(cls): + if not inspect.isclass(cls): + _qual, type_name = _utils.get_qualified_name(type(cls)) + raise TypeError("Unexpected class type '%s' (expected" + " class type only)" % type_name) + + def _cls_decorator(cls): + _check_it(cls) + out_message = _utils.generate_message( + "Using class '%s' (either directly or via inheritance)" + " is deprecated" % cls_name, postfix=None, message=message, + version=version, removal_version=removal_version) + cls.__init__ = _wrap_it(cls.__init__, out_message) + return cls + + return _cls_decorator + + def removed_module(module, replacement=None, message=None, version=None, removal_version=None, stacklevel=3, category=None): diff --git a/debtcollector/renames.py b/debtcollector/renames.py index 78f0e7c..918f565 100644 --- a/debtcollector/renames.py +++ b/debtcollector/renames.py @@ -35,7 +35,7 @@ def renamed_kwarg(old_name, new_name, message=None, def decorator(f): - @six.wraps(f) + @six.wraps(f, assigned=_utils.get_assigned(f)) def wrapper(*args, **kwargs): if old_name in kwargs: _utils.deprecation(out_message, diff --git a/debtcollector/tests/test_deprecation.py b/debtcollector/tests/test_deprecation.py index 390a3f5..94e3484 100644 --- a/debtcollector/tests/test_deprecation.py +++ b/debtcollector/tests/test_deprecation.py @@ -121,6 +121,18 @@ class EFSF_2(object): pass +@removals.removed_class("StarLord") +class StarLord(object): + def __init__(self): + self.name = "star" + + +class StarLordJr(StarLord): + def __init__(self, name): + super(StarLordJr, self).__init__() + self.name = name + + class ThingB(object): @removals.remove() def black_tristars(self): @@ -475,6 +487,24 @@ class RemovalTests(test_base.TestCase): w = capture[0] self.assertEqual(PendingDeprecationWarning, w.category) + def test_pending_warnings_emitted_class_direct(self): + with warnings.catch_warnings(record=True) as capture: + warnings.simplefilter("always") + s = StarLord() + self.assertEqual(1, len(capture)) + w = capture[0] + self.assertEqual(DeprecationWarning, w.category) + self.assertEqual("star", s.name) + + def test_pending_warnings_emitted_class_inherit(self): + with warnings.catch_warnings(record=True) as capture: + warnings.simplefilter("always") + s = StarLordJr("star_jr") + self.assertEqual(1, len(capture)) + w = capture[0] + self.assertEqual(DeprecationWarning, w.category) + self.assertEqual("star_jr", s.name) + def test_warnings_emitted_instancemethod(self): zeon = ThingB() with warnings.catch_warnings(record=True) as capture: diff --git a/doc/source/examples.rst b/doc/source/examples.rst index a9c9e7f..f0f5c8d 100644 --- a/doc/source/examples.rst +++ b/doc/source/examples.rst @@ -38,7 +38,7 @@ A basic example to do just this (on a class): >>> from debtcollector import removals >>> import warnings >>> warnings.simplefilter('always') - >>> @removals.remove + >>> @removals.removed_class("Pinto") ... class Pinto(object): ... pass ... @@ -48,7 +48,7 @@ A basic example to do just this (on a class): .. testoutput:: - __main__:1: DeprecationWarning: Using class 'Pinto' is deprecated + __main__:1: DeprecationWarning: Using class 'Pinto' (either directly or via inheritance) is deprecated A basic example to do just this (on a classmethod):