Cómo advertir sobre la obsolescencia de la clase (nombre)


He renombrado una clase python que es parte de una biblioteca. Estoy dispuesto a dejar la posibilidad de usar su nombre anterior durante algún tiempo, pero me gustaría advertir al usuario que está obsoleto y se eliminará en el futuro.

Creo que para proporcionar compatibilidad con versiones anteriores será suficiente usar un alias como ese:

class NewClsName:
    pass

OldClsName = NewClsName

No tengo idea de cómo marcar el OldClsName como obsoleto de una manera elegante. Tal vez podría hacer OldClsName una función que emite una advertencia (a los registros) y construye el objeto NewClsName a partir de sus parámetros (usando *args y **kvargs) pero no parece lo suficientemente elegante (o tal vez lo es?).

Sin embargo, no se como funcionan las advertencias de desaprobación de bibliotecas estándar de Python. Me imagino que puede haber algo de magia agradable para lidiar con la obsolescencia, por ejemplo, permitir tratarla como errores o silenciarla dependiendo de la opción de la línea de comandos de algún intérprete.

La pregunta es: Cómo advertir a los usuarios sobre el uso de un alias de clase obsoleta (u clase obsoleta en general).

EDITAR: El enfoque de función no funciona para mí (ya le di una oportunidad) porque la clase tiene algunos métodos de clase (métodos de fábrica) que no se pueden llamar cuando el OldClsName se define como una función. El siguiente código no funcionará:

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

Debido a:

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

¿Es la herencia mi única opción? Para ser honesto, no se ve muy limpio para mí-que afecta a la jerarquía de clases a través de la introducción de derivación innecesaria. Además, OldClsName is not NewClsName lo que no es un problema en la mayoría de los casos, pero puede ser un problema en caso de código mal escrito utilizando la biblioteca.

También podría crear una clase ficticia, no relacionada OldClsName e implementar un constructor, así como envoltorios para todos los métodos de clase en ella, pero es una solución aún peor, en mi opinión.

Author: James Draper, 2012-01-25

5 answers

Tal vez podría hacer OldClsName una función que emite una advertencia (para logs) y construye el objeto NewClsName a partir de sus parámetros (usando * args y * * kvargs) pero no parece lo suficientemente elegante (o tal vez lo es?).

Sí, creo que es una práctica bastante estándar:

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

Lo único complicado es que si tienes cosas que subclase de OldClsName - entonces tenemos que ser inteligentes. Si solo necesita mantener el acceso a los métodos de clase, esto debería hacerlo:

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 no lo he probado, pero eso debería darle la idea - __call__ manejará la ruta normal-instantation, __getattr__ capturará los accesos a los métodos de clase y aún generará la advertencia, sin interferir con la jerarquía de su clase.

 28
Author: AdamKG,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2012-01-25 22:01:12

Por Favor, eche un vistazo a warnings.warn.

Como verá, el ejemplo en la documentación es una advertencia de obsolescencia:

def deprecation(message):
    warnings.warn(message, DeprecationWarning, stacklevel=2)
 13
Author: jcollado,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2012-01-25 18:54:21

¿Por qué no solo sub-clase? De esta manera ningún código de usuario debe ser roto.

class OldClsName(NewClsName):
    def __init__(self, *args, **kwargs):
        warnings.warn("The 'OldClsName' class was renamed [...]",
                      DeprecationWarning)
        NewClsName.__init__(*args, **kwargs)
 4
Author: David Zwicker,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2014-10-01 00:45:22

Use el módulo inspect para agregar un marcador de posición para OldClass, luego OldClsName is NewClsName check pasará, y un linter como pylint informará esto como 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())

Luego ejecuta 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>
 1
Author: Nate Scarlet,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2018-07-23 02:30:09

Aquí está la lista de requisitos que una solución debe satisfacer:

  • La instanciación de una clase obsoleta debería generar una advertencia
  • La subclase de una clase obsoleta debería generar una advertencia
  • Soporte isinstance y issubclass comprobaciones

Solución

Esto se puede lograr con una metaclase personalizada:

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

Explicación

DeprecatedClassMeta.__new__ el método se llama no solo para una clase de la que es una metaclase, sino también para cada subclase de esta clase. Eso da la oportunidad de asegurar que ninguna instancia de DeprecatedClass nunca será instanciada o subclase.

La instanciación es simple. La metaclase anula la __new__ método de DeprecatedClass para devolver siempre una instancia de NewClass.

La subclase no es mucho más difícil. DeprecatedClassMeta.__new__ recibe una lista de clases base y necesita reemplazar instancias de DeprecatedClass por NewClass.

Finalmente, los controles isinstance y issubclass se implementan a través de __instancecheck__ y __subclasscheck__ definidos en PEP 3119.


Prueba

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
 0
Author: Kentzo,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2018-08-30 02:56:37