Comment avertir de la dépréciation de la classe (nom)
-
14-11-2019 - |
Question
J'ai renommé une classe python qui fait partie d'une bibliothèque.Je suis prêt à laisser la possibilité d'utiliser son ancien nom pendant un certain temps, mais je voudrais avertir l'utilisateur qu'il est obsolète et qu'il sera supprimé à l'avenir.
Je pense que pour assurer la rétrocompatibilité, il suffira d'utiliser un alias comme celui-ci :
class NewClsName:
pass
OldClsName = NewClsName
Je ne sais pas comment marquer le OldClsName
comme obsolète d'une manière élégante.Peut-être que je pourrais faire OldClsName
une fonction qui émet un avertissement (aux journaux) et construit le NewClsName
objet à partir de ses paramètres (en utilisant *args
et **kvargs
) mais cela ne semble pas assez élégant (ou peut-être que ça l'est ?).
Cependant, je ne sais pas comment fonctionnent les avertissements de dépréciation de la bibliothèque standard Python.J'imagine qu'il peut y avoir une belle magie pour gérer la dépréciation, par ex.permettant de le traiter comme des erreurs ou de le faire taire en fonction de l'option de ligne de commande de certains interprètes.
La question est:Comment avertir les utilisateurs de l'utilisation d'un alias de classe obsolète (ou d'une classe obsolète en général).
MODIFIER:L'approche fonction ne fonctionne pas pour moi (je l'ai déjà essayé) car la classe possède des méthodes de classe (méthodes d'usine) qui ne peuvent pas être appelées lorsque le OldClsName
est défini comme une fonction.Le code suivant ne fonctionnera pas :
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( ... )
À cause de:
AttributeError: 'function' object has no attribute 'CreateVariant1'
L’héritage est-il ma seule option ?Pour être honnête, cela ne me semble pas très clair - cela affecte la hiérarchie des classes en introduisant des dérivations inutiles.En plus, OldClsName is not NewClsName
ce qui n'est pas un problème dans la plupart des cas mais peut l'être en cas de code mal écrit utilisant la bibliothèque.
Je pourrais aussi créer un mannequin, sans rapport OldClsName
class et implémenter un constructeur ainsi que des wrappers pour toutes les méthodes de classe qu'il contient, mais c'est une solution encore pire, à mon avis.
La solution
Peut-être que je pourrais faire d'OldclsName une fonction qui émet un avertissement (aux journaux) et construit l'objet newclsName de ses paramètres (en utilisant * args et ** kvargs) mais cela ne semble pas assez élégant (ou peut-être c'est?).
Ouais, je pense que c'est une pratique assez courante :
def OldClsName(*args, **kwargs):
from warnings import warn
warn("get with the program!")
return NewClsName(*args, **kwargs)
La seule chose délicate est que si vous avez des éléments qui sous-classent de OldClsName
- alors nous devons être intelligents.Si vous avez juste besoin de conserver l’accès aux méthodes de classe, ceci devrait le faire :
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)
Je ne l'ai pas testé, mais cela devrait vous donner une idée - __call__
gérera la route d'instanciation normale, __getattr__
capturera les accès aux méthodes de classe et générera toujours l'avertissement, sans perturber la hiérarchie de votre classe.
Autres conseils
Veuillez jeter un oeil à warnings.warn
.
Comme vous le verrez, l'exemple dans la documentation est un avertissement de dépréciation :
def deprecation(message):
warnings.warn(message, DeprecationWarning, stacklevel=2)
Pourquoi ne pas simplement sous-classer ?De cette façon, aucun code utilisateur ne devrait être cassé.
class OldClsName(NewClsName):
def __init__(self, *args, **kwargs):
warnings.warn("The 'OldClsName' class was renamed [...]",
DeprecationWarning)
NewClsName.__init__(*args, **kwargs)
Voici la liste des exigences auxquelles une solution doit satisfaire :
- L'instanciation d'une classe obsolète devrait déclencher un avertissement
- Le sous-classement d'une classe obsolète devrait déclencher un avertissement
- Soutien
isinstance
etissubclass
chèques
Solution
Ceci peut être réalisé avec une métaclasse personnalisée :
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'))
Explication
DeprecatedClassMeta.__new__
La méthode est appelée non seulement pour une classe dont elle est une métaclasse, mais également pour chaque sous-classe de cette classe.Cela donne l'occasion de garantir qu'aucun cas de DeprecatedClass
sera jamais instancié ou sous-classé.
L'instanciation est simple.La métaclasse remplace la __new__
méthode de DeprecatedClass
pour toujours renvoyer une instance de NewClass
.
Le sous-classement n’est pas beaucoup plus difficile. DeprecatedClassMeta.__new__
reçoit une liste de classes de base et doit remplacer les instances de DeprecatedClass
avec NewClass
.
Finalement, le isinstance
et issubclass
les contrôles sont effectués via __instancecheck__
et __subclasscheck__
défini dans 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
En python >= 3.6, vous pouvez facilement gérer les avertissements lors du sous-classement :
class OldClassName(NewClassName):
def __init_subclass__(self):
warn("Class has been renamed NewClassName", DeprecationWarning, 2)
Surcharge __new__
devrait vous permettre d'avertir lorsque l'ancien constructeur de classe est appelé directement, mais je n'ai pas testé cela car je n'en ai pas besoin pour le moment.
Utiliser inspect
module pour ajouter un espace réservé pour OldClass
, alors OldClsName is NewClsName
le contrôle réussira et un linter comme pylint l'informera comme une erreur.
obsolète.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())
puis cours 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>
Depuis Python 3.7, vous pouvez fournir une personnalisation de l'accès aux attributs du module en utilisant __getattr__
(et __dir__
).Tout est expliqué dans PPE 562.Dans l'exemple ci-dessous, j'ai implémenté __getattr__
et __dir__
afin de déprécier le « OldClsName » au profit de « 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])
Dans le __getattr__
fonction, si une classe ou un nom de fonction obsolète est trouvé, un message d'avertissement est émis, affichant le fichier source et le numéro de ligne de l'appelant (avec stacklevel=2
).
Dans le code utilisateur, on pourrait avoir :
# 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()
Lorsque l'utilisateur exécute son script your_lib_usage.py
, cela donnera quelque chose comme ceci :
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: la trace de la pile est généralement écrite en STDERR.
Pour voir les avertissements d'erreur, vous devrez peut-être ajouter un indicateur « -W » dans la ligne de commande Python, par exemple :
python -W always your_lib_usage.py