如何警告类(名称)已被弃用

75

我已经重命名了一个python类,该类是库的一部分。我愿意在一段时间内保留使用其先前名称的可能性,但希望警告用户该名称已被弃用,并将来会被移除。

我认为,为了提供向后兼容性,只需要使用类似如下的别名即可:

class NewClsName:
    pass

OldClsName = NewClsName

我不知道如何以一种优雅的方式标记 OldClsName 为已废弃。也许我可以将 OldClsName 作为一个函数,并发出警告(到日志),然后使用其参数(使用*args**kvargs)构造 NewClsName 对象,但这似乎不够优雅(或者它确实够优雅?)

然而,我并不知道Python标准库中的弃用警告是如何工作的。我想象可能有一些很好的魔法来处理弃用,例如根据一些解释器的命令行选项允许将其视为错误或消除警告。

问题是:如何警告用户使用已过时的类别名(或过时的类别)。

编辑:对我来说,函数方法不可行(我已经尝试过了),因为该类别还有一些类别方法(工厂方法),当定义为函数时无法调用 OldClsName。以下代码将无法正常工作:

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( ... )

因为:

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

继承是我的唯一选择吗?说实话,这种方式对我来说并不太干净 - 它通过引入不必要的派生影响类层次结构。此外,OldClsName不是NewClsName 在大多数情况下不是问题,但是在使用库的糟糕编码的情况下可能会成为问题。

我还可以创建一个虚拟的、不相关的 OldClsName 类,并在其中实现一个构造函数以及所有类方法的包装器,但我认为这甚至更糟。


我们通常不应该使用内置的注解@DeprecationWarning吗? - Albert Hendriks
7个回答

47

也许我可以将OldClsName作为一个函数,该函数发出警告(到日志)并使用*args和**kvargs从其参数构造NewClsName对象,但这似乎不够优雅(或者说是优雅的吗?)。

是的,我认为这是相当标准的做法:

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

唯一棘手的是,如果您有从 OldClsName 继承的东西 - 那么我们必须变得聪明。如果您只需要保持对类方法的访问权限,那么应该这样做:

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)

我没有测试过,但这应该可以给你一个想法 - __call__ 将处理常规实例化路线,__getattr__ 将捕获对类方法的访问并仍然生成警告,而不会影响你的类继承结构。


2
支持继承也应该很容易 - 只需编写一个 class OldClsName(NewClsName): # and overload __new__ - user395760
1
+1 确认使用此解决方案并提到 warnings 模块。不幸的是,函数解决方案对我无效(请参见编辑后的问题)。也许您有其他简洁的解决方案? :D - Dariusz Walczak
更新了一个使用包装对象的示例,该对象可以代理对新类的调用和属性访问。 - AdamKG
3
它将无法通过如isinstance(NewClsName(), OldClsName)或issubclass(NewClsName, OldClsName)这样的检查。 - Kentzo

22
请查看 warnings.warn
正如您所看到的,在文档中的示例是一个弃用警告。
def deprecation(message):
    warnings.warn(message, DeprecationWarning, stacklevel=2)

8
这是关于废弃一个类,而不是废弃一个方法/函数。 - ACimander
1
这让我得到了我关心的行为,即PyCharm将在具有warnings.warn作为其第一行的方法的任何用法上添加删除线。因此,通过将此放入类的__init__/__init_subclass中,您在尝试使用它时会收到删除线。但是,如果像这样将其包装在方法中,它将不起作用。 - Noumenon

11

在 Python >= 3.6 中,您可以轻松处理子类化警告:

from warnings import warn

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

重载 __new__ 应该允许您在直接调用旧类构造函数时发出警告,但我现在不需要它,因此没有测试过。


1
无论是否使用已弃用的类,这都会始终发出警告。 - a_guest
@a_guest 我无法复制这个问题。对我来说它很好用:只有在子类化弃用的类时才会发出警告。 - Alice Purcell
@AlicePurcell 我的意思是,这将限制类的“使用”仅限于“子类化”。如果它从未被子类化,但是从中创建了实例,则不会产生警告。正确的处理方式是通过模块级别的__getattr__函数来一般性地限制对名称 OldClassName 的访问。 - a_guest
我认为“正确”的定义取决于您的使用意图。例如,如果您在__init__.py中“转发”旧名称,则无法使用__getattr__。对于我的用例,这个方法使用的代码更少 :) - Alice Purcell

9
以下是需要翻译的内容:

以下是解决方案应满足的要求列表:

  • 实例化过时类应发出警告
  • 子类化过时类应发出警告
  • 支持isinstanceissubclass检查

解决方案

这可以通过自定义元类来实现:

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'))

解释

DeprecatedClassMeta.__new__ 方法被调用不仅针对它是元类的类,而且还针对这个类的每个子类。这就给了机会确保 DeprecatedClass 的实例永远不会被实例化或继承。

实例化很简单。元类重写了 __new__ 方法来总是返回一个 NewClass 实例。

继承并不难。 DeprecatedClassMeta.__new__ 接收一个基类列表,并需要将 DeprecatedClass 的实例替换为 NewClass 的实例。

最后,isinstanceissubclass 检查是通过在 PEP 3119 中定义的 __instancecheck____subclasscheck__ 实现的。


测试

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

1
非常好的回答,我认为应该被采纳。 - Hildy

7

自Python 3.7起,您可以使用__getattr__(和__dir__)提供模块属性访问的定制。有关详细信息,请参见PEP 562。 在下面的示例中,我实现了__getattr____dir__,以便弃用“OldClsName”,支持“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])

__getattr__函数中,如果发现已弃用的类或函数名称,将发出警告消息,显示调用者的源文件和行号(使用stacklevel=2)。
在用户代码中,我们可以有以下内容:
# 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()

当用户运行他的脚本your_lib_usage.py时,它会得到类似于这样的东西:
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
<注意>堆栈跟踪通常写在STDERR中。
为了查看错误警告,您可能需要在Python命令行中添加“-W”标志,例如:
python -W always your_lib_usage.py

6
为什么不使用子类继承呢?这样就不会破坏任何用户代码。
class OldClsName(NewClsName):
    def __init__(self, *args, **kwargs):
        warnings.warn("The 'OldClsName' class was renamed [...]",
                      DeprecationWarning)
        NewClsName.__init__(*args, **kwargs)

isinstance 还会检查子类,所以如果这是你想要表达的意思,那么这不会破坏任何东西。 - David Zwicker
2
isinstance检查将会失败。考虑一下,你有一个遗留代码使用了isinstance(obj, OldClsName),而新的代码实例化了obj = NewClsName(),那么isinstance(obj, OldClsName) == False,你的代码就会出问题:糟糕。 - DylanYoung
1
@DylanYoung 别忘了 issubclass()。你可以使用 __instancecheck____subclasscheck__ 来实现。 - Błażej Michalik

2
使用inspect模块为OldClass添加占位符,然后OldClsName is NewClsName检查将通过,像pylint这样的代码检查工具将会报错。 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())

然后运行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>

如果将不良名称用作类实例的变量名,例如在其他类中使用self.Test2_old = Test2(),则此操作将失败。 - Hubert Kario

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接