How to warn about class (name) deprecation
Asked Answered
E

7

75

I have renamed a python class that is part of a library. I am willing to leave a possibility to use its previous name for some time but would like to warn user that it's deprecated and will be removed in the future.

I think that to provide backward compatibility it will be enough to use an alias like that:

class NewClsName:
    pass

OldClsName = NewClsName

I have no idea how to mark the OldClsName as deprecated in an elegant way. Maybe I could make OldClsName a function which emits a warning (to logs) and constructs the NewClsName object from its parameters (using *args and **kvargs) but it doesn't seem elegant enough (or maybe it is?).

However, I don't know how Python standard library deprecation warnings work. I imagine that there may be some nice magic to deal with deprecation, e.g. allowing treating it as errors or silencing depending on some interpreter's command line option.

The question is: How to warn users about using an obsolete class alias (or obsolete class in general).

EDIT: The function approach doesn't work for me (I already gave it a try) because the class has some class methods (factory methods) which can't be called when the OldClsName is defined as a function. Following code won't work:

class NewClsName(object):
    @classmethod
    def CreateVariant1( cls, ... ):
        pass

    @classmethod
    def CreateVariant2( cls, ... ):
        pass

def OldClsName(*args, **kwargs):
    warnings.warn("The 'OldClsName' class was renamed [...]",
                  DeprecationWarning )
    return NewClsName(*args, **kwargs)

OldClsName.CreateVariant1( ... )

Because of:

AttributeError: 'function' object has no attribute 'CreateVariant1'

Is inheritance my only option? To be honest, it doesn't look very clean to me - it affects class hierarchy through introduction of unnecessary derivation. Additionally, OldClsName is not NewClsName what is not an issue in most cases but may be a problem in case of poorly written code using the library.

I could also create a dummy, unrelated OldClsName class and implement a constructor as well as wrappers for all class methods in it, but it is even worse solution, in my opinion.

Elata answered 25/1, 2012 at 18:51 Comment(1)
Shouldn't we normally use the builtin annotation @DeprecationWarning for this?Atal
M
49

Maybe I could make OldClsName a function which emits a warning (to logs) and constructs the NewClsName object from its parameters (using *args and **kvargs) but it doesn't seem elegant enough (or maybe it is?).

Yup, I think that's pretty standard practice:

def OldClsName(*args, **kwargs):
    from warnings import warn
    warn("get with the program!")
    return NewClsName(*args, **kwargs)

The only tricky thing is if you have things that subclass from OldClsName - then we have to get clever. If you just need to keep access to class methods, this should do it:

class DeprecationHelper(object):
    def __init__(self, new_target):
        self.new_target = new_target

    def _warn(self):
        from warnings import warn
        warn("Get with the program!")

    def __call__(self, *args, **kwargs):
        self._warn()
        return self.new_target(*args, **kwargs)

    def __getattr__(self, attr):
        self._warn()
        return getattr(self.new_target, attr)

OldClsName = DeprecationHelper(NewClsName)

I haven't tested it, but that should give you the idea - __call__ will handle the normal-instantation route, __getattr__ will capture accesses to the class methods & still generate the warning, without messing with your class heirarchy.

Martinsen answered 25/1, 2012 at 18:55 Comment(4)
Supporting inheritance should be easy as well - just write a class OldClsName(NewClsName): # and overload __new__.Divertimento
+1 for confirming that this solution is used and for mentioning the warnings module. Unfortunately the function solution doesn't work for me (see edited question). Maybe you have some other clean solutions? :DElata
Updated with an example of using a wrapper object that can proxy both calls & attribute accesses to the new class.Martinsen
It will not pass checks sucks as isinstance(NewClsName(), OldClsName) or issubclass(NewClsName, OldClsName).Papst
E
23

Please have a look at warnings.warn.

As you'll see, the example in the documentation is a deprecation warning:

def deprecation(message):
    warnings.warn(message, DeprecationWarning, stacklevel=2)
Embrey answered 25/1, 2012 at 18:54 Comment(2)
this was about deprecating a class, not deprecating a method/function.Kristoforo
This gives me the behavior I care about, which is that PyCharm will strikethrough any usages of a method that has warnings.warn as its first line. Thus by putting this in a class's __init__/__init_subclass, you get strikethrough when you try to use it. It won't work if you wrap it in a method like this, though.Lectern
O
13

In python >= 3.6 you can easily handle warning on subclassing:

from warnings import warn

class OldClassName(NewClassName):
    def __init_subclass__(self):
        warn("Class has been renamed NewClassName", DeprecationWarning, 2)

Overloading __new__ should allow you to warn when the old class constructor is called directly, but I haven't tested that since I don't need it right now.

Orthopteran answered 23/11, 2018 at 16:23 Comment(4)
This will always issue the warning, no matter if the deprecated class is being used or not.Cadena
@Cadena I can't replicate this. It works fine for me: a warning is only issued if the deprecated class is subclassed.Matronly
@AlicePurcell What I meant to say is, that this restricts "usage" of the class to "subclassing". If it never gets subclassed, but instances are created from it, then no warning will be produced. The correct way to handle this is to restrict access to the name OldClassName in general via a module-level __getattr__ function.Cadena
I think what is "correct" depends on your intended usage. Doing __getattr__ for instance doesn't work if you then "forward" the old name in an __init__.py. This one worked for my use case with much less code :)Matronly
P
9

Here is the list of requirements a solution should satisfy:

  • Instantiation of a deprecated class should raise a warning
  • Subclassing of a deprecated class should raise a warning
  • Support isinstance and issubclass checks

Solution

This can be achieved with a custom metaclass:

class DeprecatedClassMeta(type):
    def __new__(cls, name, bases, classdict, *args, **kwargs):
        alias = classdict.get('_DeprecatedClassMeta__alias')

        if alias is not None:
            def new(cls, *args, **kwargs):
                alias = getattr(cls, '_DeprecatedClassMeta__alias')

                if alias is not None:
                    warn("{} has been renamed to {}, the alias will be "
                         "removed in the future".format(cls.__name__,
                             alias.__name__), DeprecationWarning, stacklevel=2)

                return alias(*args, **kwargs)

            classdict['__new__'] = new
            classdict['_DeprecatedClassMeta__alias'] = alias

        fixed_bases = []

        for b in bases:
            alias = getattr(b, '_DeprecatedClassMeta__alias', None)

            if alias is not None:
                warn("{} has been renamed to {}, the alias will be "
                     "removed in the future".format(b.__name__,
                         alias.__name__), DeprecationWarning, stacklevel=2)

            # Avoid duplicate base classes.
            b = alias or b
            if b not in fixed_bases:
                fixed_bases.append(b)

        fixed_bases = tuple(fixed_bases)

        return super().__new__(cls, name, fixed_bases, classdict,
                               *args, **kwargs)

    def __instancecheck__(cls, instance):
        return any(cls.__subclasscheck__(c)
            for c in {type(instance), instance.__class__})

    def __subclasscheck__(cls, subclass):
        if subclass is cls:
            return True
        else:
            return issubclass(subclass, getattr(cls,
                              '_DeprecatedClassMeta__alias'))

Explanation

DeprecatedClassMeta.__new__ method is called not only for a class it is a metaclass of but also for every subclass of this class. That gives an opportunity to ensure that no instance of DeprecatedClass will ever be instantiated or subclassed.

Instantiation is simple. The metaclass overrides the __new__ method of DeprecatedClass to always return an instance of NewClass.

Subclassing is not much harder. DeprecatedClassMeta.__new__ receives a list of base classes and needs to replace instances of DeprecatedClass with NewClass.

Finally, the isinstance and issubclass checks are implemented via __instancecheck__ and __subclasscheck__ defined in PEP 3119.


Test

class NewClass:
    foo = 1


class NewClassSubclass(NewClass):
    pass


class DeprecatedClass(metaclass=DeprecatedClassMeta):
    _DeprecatedClassMeta__alias = NewClass


class DeprecatedClassSubclass(DeprecatedClass):
    foo = 2


class DeprecatedClassSubSubclass(DeprecatedClassSubclass):
    foo = 3


assert issubclass(DeprecatedClass, DeprecatedClass)
assert issubclass(DeprecatedClassSubclass, DeprecatedClass)
assert issubclass(DeprecatedClassSubSubclass, DeprecatedClass)
assert issubclass(NewClass, DeprecatedClass)
assert issubclass(NewClassSubclass, DeprecatedClass)

assert issubclass(DeprecatedClassSubclass, NewClass)
assert issubclass(DeprecatedClassSubSubclass, NewClass)

assert isinstance(DeprecatedClass(), DeprecatedClass)
assert isinstance(DeprecatedClassSubclass(), DeprecatedClass)
assert isinstance(DeprecatedClassSubSubclass(), DeprecatedClass)
assert isinstance(NewClass(), DeprecatedClass)
assert isinstance(NewClassSubclass(), DeprecatedClass)

assert isinstance(DeprecatedClassSubclass(), NewClass)
assert isinstance(DeprecatedClassSubSubclass(), NewClass)

assert NewClass().foo == 1
assert DeprecatedClass().foo == 1
assert DeprecatedClassSubclass().foo == 2
assert DeprecatedClassSubSubclass().foo == 3
Papst answered 30/8, 2018 at 0:50 Comment(1)
Excellent answer, and should be the accepted on IMO.Chile
G
7

Since Python 3.7, you can provide a customization of module attribute access using __getattr__ (and __dir__). Everything is explained in PEP 562. In the bellow example, I implemented __getattr__ and __dir__ in order to deprecate the “OldClsName” in favor of “NewClsNam”:

# your_lib.py

import warnings

__all__ = ["NewClsName"]

DEPRECATED_NAMES = [('OldClsName', 'NewClsName')]


class NewClsName:
    @classmethod
    def create_variant1(cls):
        return cls()


def __getattr__(name):
    for old_name, new_name in DEPRECATED_NAMES:
        if name == old_name:
            warnings.warn(f"The '{old_name}' class or function is renamed '{new_name}'",
                          DeprecationWarning,
                          stacklevel=2)
            return globals()[new_name]
    raise AttributeError(f"module {__name__} has no attribute {name}")


def __dir__():
    return sorted(__all__ + [names[0] for names in DEPRECATED_NAMES])

In the __getattr__ function, if a deprecated class or function name is found, a warning message is emitted, showing the source file and line number of the caller (with stacklevel=2).

In the user code, we could have:

# your_lib_usage.py
from your_lib import NewClsName
from your_lib import OldClsName


def use_new_class():
    obj = NewClsName.create_variant1()
    print(obj.__class__.__name__ + " is created in use_new_class")


def use_old_class():
    obj = OldClsName.create_variant1()
    print(obj.__class__.__name__ + " is created in use_old_class")


if __name__ == '__main__':
    use_new_class()
    use_old_class()

When the user run his script your_lib_usage.py, it will get something like this:

NewClsName is created in use_new_class
NewClsName is created in use_old_class
/path/to/your_lib_usage.py:3: DeprecationWarning: The 'OldClsName' class or function is renamed 'NewClsName'
  from your_lib import OldClsName

Note: the stack trace is usually written in STDERR.

To see the error warnings, you may need to add a “-W” flag in the Python command line, for instance:

python -W always your_lib_usage.py
Gander answered 13/3, 2019 at 10:27 Comment(0)
F
6

Why don't you just sub-class? This way no user code should be broken.

class OldClsName(NewClsName):
    def __init__(self, *args, **kwargs):
        warnings.warn("The 'OldClsName' class was renamed [...]",
                      DeprecationWarning)
        NewClsName.__init__(*args, **kwargs)
Firewood answered 30/9, 2014 at 22:10 Comment(3)
isinstance also checks for subclasses, so this should not break anything if this is what you tried to imply.Firewood
isinstance checks will indeed fail. Consider, you've got legacy code doing isinstance(obj, OldClsName) and new code that instantiates obj = NewClsName() then isinstance(obj, OldClsName) == False and your code breaks: oops.Beatify
@Beatify Don't forget issubclass(). You can use __instancecheck__ and __subclasscheck__ for those.Darill
A
2

Use inspect module to add placeholder for OldClass, then OldClsName is NewClsName check will pass, and a linter like pylint will inform this as error.

deprecate.py

import inspect
import warnings
from functools import wraps

def renamed(old_name):
    """Return decorator for renamed callable.

    Args:
        old_name (str): This name will still accessible,
            but call it will result a warn.

    Returns:
        decorator: this will do the setting about `old_name`
            in the caller's module namespace.
    """

    def _wrap(obj):
        assert callable(obj)

        def _warn():
            warnings.warn('Renamed: {} -> {}'
                        .format(old_name, obj.__name__),
                        DeprecationWarning, stacklevel=3)

        def _wrap_with_warn(func, is_inspect):
            @wraps(func)
            def _func(*args, **kwargs):
                if is_inspect:
                    # XXX: If use another name to call,
                    # you will not get the warning.
                    frame = inspect.currentframe().f_back
                    code = inspect.getframeinfo(frame).code_context
                    if [line for line in code
                            if old_name in line]:
                        _warn()
                else:
                    _warn()
                return func(*args, **kwargs)
            return _func

        # Make old name available.
        frame = inspect.currentframe().f_back
        assert old_name not in frame.f_globals, (
            'Name already in use.', old_name)

        if inspect.isclass(obj):
            obj.__init__ = _wrap_with_warn(obj.__init__, True)
            placeholder = obj
        else:
            placeholder = _wrap_with_warn(obj, False)

        frame.f_globals[old_name] = placeholder

        return obj

    return _wrap

test.py

from __future__ import print_function

from deprecate import renamed


@renamed('test1_old')
def test1():
    return 'test1'


@renamed('Test2_old')
class Test2(object):
    pass

    def __init__(self):
        self.data = 'test2_data'

    def method(self):
        return self.data

# pylint: disable=undefined-variable
# If not use this inline pylint option, 
# there will be E0602 for each old name.
assert(test1() == test1_old())
assert(Test2_old is Test2)
print('# Call new name')
print(Test2())
print('# Call old name')
print(Test2_old())

then run python -W all test.py:

test.py:22: DeprecationWarning: Renamed: test1_old -> test1
# Call new name
<__main__.Test2 object at 0x0000000007A147B8>
# Call old name
test.py:27: DeprecationWarning: Renamed: Test2_old -> Test2
<__main__.Test2 object at 0x0000000007A147B8>
Aintab answered 25/2, 2018 at 22:31 Comment(1)
this will fail if the bad name is used as a variable name of class instances; eg having self.Test2_old = Test2() in some other classDisqualify

© 2022 - 2024 — McMap. All rights reserved.