Skip to content

Instantly share code, notes, and snippets.

@Kentzo
Last active September 14, 2018 00:04
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Kentzo/53df97c7a54609d3febf5f8eb6b67118 to your computer and use it in GitHub Desktop.
Save Kentzo/53df97c7a54609d3febf5f8eb6b67118 to your computer and use it in GitHub Desktop.
The deprecated extension for the warnings module
from deprecated import *
# --- Class ---
# Class can be deprecated
# -> Every instantiation, subclassing or usage as a metaclass
# will issue a warning
@deprecated
class Foo: pass
# Class can be replaced
# -> Every instantiation, subclassing or usage as a metaclass
# will issue a warning and use new class instead.
# isinstance / issubclass will pass against obsolete class
class Bar: pass
@obsolete(new=Bar)
class Foo: pass
# And signature of its constructor may be mapped
# the same way it works for functions
class Bar:
def __init__(self, username): pass
@obsolete(new=Bar)
@obsolete_arg(name='username')
class Foo: pass
# --- Function ---
# Function can be deprecated
# -> Every call will issue a warning
@deprecated
def foo(): pass
# Or replaced
# -> Every call will issue a warning and forward to the replacement
def bar(): pass
@obsolete(new=bar)
def foo(): pass
# Only function's signature may change
# -> Every argument usage will issue a warning and map the argument name
@obsolete_arg({'name': 'username'})
def foo(username, first_name, last_name): pass
# Or it can be replaced with trivially different signature
# -> Every call will issue a warning and forward to the replacement
# after mapping arguments
def bar(username): pass
@obsolete(new=bar)
@obsolete_arg(name='username')
def foo(name): pass
# The signature difference can be not so trivial
def bar(password_hash): pass
@obsolete(new=bar)
@obsolete_arg({'password': lambda x: {'password_hash': hash(x)}})
def foo(password): pass
# Or even completly different
def transform(args, kwargs):
return args, kwargs
@obsolete(new=bar, argsmap=transform)
def foo(password): pass
# Works for instance methods
class Foo:
def bar(self): pass
@obsolete(bar)
def baz(self): pass
# And class methods
class Foo:
@classmethod
def bar(cls): pass
@classmethod
@obsolete('bar')
def baz(cls): pass
# Even properties
class Foo:
@property
def bar(self): pass
@obsolete('bar')
@property
def baz(self): pass
# --- Context Manager ---
# Sometimes external signature remains, but its expected
# usage pattern change. E.g. when certain arguments become
# mutually exclusive.
def foo(name, first_name, last_name):
if name and (first_name or last_name):
with deprecated("'name' is mutually exclusive to 'first_name' and 'last_name'"):
name = '{} {}'.format(first_name, last_name)
# --- Customization ---
# Issued messages can be customized by providing a value for the 'reason' argument
class CustomReason(Reason):
CLS_OBSOLETE = "do not use '{obsolete}'; use '{new}' instead"
@obsolete(reason=CustomReason())
def foo(): pass
# Depending on the situation, custom message may receive any of the following replacements:
# - obsolete: __qualname__ of an object being replaced
# - new: __qualname__ of the replacement object
# - deprecated: __qualname__ of a deprecated object
# - current: __qualname__ of a class whose base class is obsolete or deprecated
# The way warnings are issued can be customized by providing a value for the 'warn' argument
@obsolete(warn=...)
def foo() pass
import copy
import dataclasses
import functools
import inspect
from typing import Any, Callable, Dict, Optional, \
Sequence, Tuple, Type, TypeVar, Union, overload
import warnings
T = TypeVar('T')
TypeT = TypeVar('TypeT', bound=Type[Any])
CallableT = TypeVar('CallableT', bound=Callable)
ArgsMapValueTransformT = Callable[[Any], Tuple[str, Any]]
ArgsMapKeyT = Union[str, int]
ArgsMapValueT = Union[str, ArgsMapValueTransformT]
ArgsMappingT = Dict[ArgsMapKeyT, ArgsMapValueT]
ArgsMapTransformT = Callable[[Sequence, Dict], Tuple[Sequence, Dict]]
ArgsMapT = Union[ArgsMappingT, ArgsMapTransformT]
@dataclasses.dataclass(frozen=True)
class Reason:
CLS_OBSOLETE: str = "'{obsolete}' has been replaced with '{new}'"
CLS_BASE_OBSOLETE: str = "'{current}' base class '{obsolete}' has been " \
"replaced with '{new}'"
CLS_DEPRECATED: str = "'{deprecated}' is deprecated"
CLS_BASE_DEPRECATED: str = "'{current}' base class '{deprecated}' is deprecated"
FUNC_OBSOLETE: str = CLS_OBSOLETE
FUNC_DEPRECATED: str = CLS_DEPRECATED
FUNC_KWARG_OBSOLETE: str = "keyword argument '{obsolete}' has been " \
"replaced with '{new}'"
FUNC_KWARG_DEPRECATED: str = "keyword argument '{deprecated}' is deprecated"
FUNC_POSARG_OBSOLETE: str = "positional argument '{obsolete}' has been " \
"replaced with '{new}'"
FUNC_POSARG_DEPRECATED: str = "positional argument '{deprecated}' is deprecated"
PROP_OBSOLETE: str = CLS_OBSOLETE
PROP_DEPRECATED: str = CLS_DEPRECATED
CM_DEPRECATED: str = "Deprecated"
CUSTOM: str = None
def __getattribute__(self, name):
if name != 'CUSTOM' and self.CUSTOM and name in object.__getattribute__(self, '__dataclass_fields__'):
return self.CUSTOM
return super().__getattribute__(name)
@staticmethod
@functools.lru_cache(maxsize=None)
def with_reason(reason: str) -> 'Reason':
return Reason(CUSTOM=reason)
class DeprecatedMetaBase(type):
def __new__(cls, name, bases, classdict, *args, **kwargs):
fixed_bases = []
for b in bases:
d = b.__dict__.get('__deprecated__')
if d is None:
fixed_bases.append(b)
elif d.new is None:
if not d.allow_usage:
d.warn(message=d.reason.CLS_BASE_DEPRECATED.format(
current=name, deprecated=b.__qualname__))
fixed_bases.append(b)
else:
d.warn(message=d.reason.CLS_BASE_OBSOLETE.format(
current=name, obsolete=b.__qualname__,
new=d.new.__qualname__))
fixed_bases.append(d.new)
return super().__new__(cls, name, tuple(fixed_bases), classdict,
*args, **kwargs)
def __call__(cls, *args, **kwargs):
d = cls.__dict__.get('__deprecated__')
if d is None:
return super().__call__(*args, **kwargs)
message = None
if d.new is not None:
message = d.reason.CLS_OBSOLETE.format(obsolete=cls.__qualname__,
new=d.new.__qualname__)
call = d.new
else:
if not d.allow_usage:
message = d.reason.CLS_DEPRECATED.format(deprecated=cls.__qualname__)
call = super().__call__
if message is not None:
d.warn(message=message)
args, kwargs = d.map_arguments(args, kwargs)
return call(*args, **kwargs)
def __instancecheck__(cls, instance):
return issubclass(type(instance), cls) or \
issubclass(instance.__class__, cls) or \
super().__instancecheck__(instance)
def __subclasscheck__(cls, subclass):
if cls is subclass:
return True
d = cls.__dict__.get('__deprecated__')
if d is not None and d.new is not None:
return issubclass(subclass, d.new)
return super().__subclasscheck__(subclass)
class DeprecatedProperty(property):
"""
Wrap fget / fset / fdel to issue a warning and forward the call.
"""
__deprecated__: 'Deprecated'
def _make_wrapper(self, obsolete: Callable, attr: str, deprecated: 'Deprecated') -> Callable:
@functools.wraps(obsolete)
def wrapper(*args, **kwargs):
cls = type(args[0])
if deprecated.new is not None:
new = getattr(getattr(cls, deprecated.new), attr)
deprecated.warn(message=deprecated.reason.PROP_OBSOLETE.format(
obsolete=obsolete.__qualname__, new=new.__qualname__),
stacklevel=3)
return new(*args, **kwargs)
else:
deprecated.warn(message=deprecated.reason.PROP_DEPRECATED.format(
deprecated=obsolete.__qualname__), stacklevel=3)
return obsolete(*args, **kwargs)
wrapper.__deprecated__ = deprecated
return wrapper
def __init__(self, fget=None, fset=None, fdel=None, doc=None, *, deprecated=None):
kwargs = {'fget': fget, 'fset': fset, 'fdel': fdel}
self.__deprecated__ = deprecated
for func in kwargs.values():
if self.__deprecated__ is not None:
break
self.__deprecated__ = getattr(func, '__deprecated__', None)
assert self.__deprecated__ is not None
for attr in kwargs:
obsolete = kwargs[attr]
if obsolete is None:
continue
elif hasattr(obsolete, '__deprecated__'):
assert obsolete.__deprecated__ is self.__deprecated__
continue
kwargs[attr] = self._make_wrapper(obsolete, attr, self.__deprecated__)
super().__init__(**kwargs, doc=doc)
def DeprecateFunction(func, *, deprecated):
@functools.wraps(func)
def wrapper(*args, **kwargs):
call = func
message = None
if deprecated.new:
if not callable(deprecated.new):
if len(args):
# args[0] is a namespace where new is defined
call = getattr(args[0], deprecated.new)
args = args[1:]
else:
raise TypeError('cannot reference new by '
'name without a namespace')
else:
call = deprecated.new
message = deprecated.reason.FUNC_OBSOLETE.format(
obsolete=func.__qualname__, new=call.__qualname__)
elif not deprecated.allow_usage:
message = deprecated.reason.FUNC_DEPRECATED.format(
deprecated=func.__qualname__)
if message is not None:
deprecated.warn(message=message, stacklevel=2)
if not deprecated._argsmap_resolved:
deprecated.resolve_argsmap(func)
deprecated._argsmap_resolved = True
args, kwargs = deprecated.map_arguments(args, kwargs)
return call(*args, **kwargs)
wrapper.__deprecated__ = deprecated
return wrapper
DEFAULT_REASON = Reason()
DEFAULT_WARN = functools.partial(warnings.warn, category=DeprecationWarning)
class Deprecated:
def __init__(self, reason=None, new=None, argsmap=None, warn=None, allow_usage=False):
self._reason = reason
self._new = new
self._argsmap = argsmap
self._warn = warn
self._allow_usage = allow_usage
self._argsmap_resolved = False
@property
def reason(self) -> Reason:
if isinstance(self._reason, str):
return Reason.with_reason(self._reason)
else:
return self._reason or DEFAULT_REASON
@property
def new(self) -> Optional[Union[Type, Callable, str]]:
return self._new
@property
def argsmap(self) -> Optional[ArgsMapT]:
return self._argsmap
@property
def warn(self) -> Callable:
return self._warn or DEFAULT_WARN
@property
def allow_usage(self) -> bool:
return self._allow_usage
def deprecate_property(self, prop: property) -> property:
prop = DeprecatedProperty(prop.fget, prop.fset, prop.fdel, prop.__doc__,
deprecated=copy.deepcopy(self))
return prop
def deprecate_class(self, cls: TypeT) -> TypeT:
class DeprecatedMeta(DeprecatedMetaBase, type(cls)):
pass
classdict = cls.__dict__.copy()
classdict['__deprecated__'] = copy.deepcopy(self)
return DeprecatedMeta(cls.__name__, cls.__bases__, classdict)
def deprecate_function(self, func: CallableT) -> CallableT:
return DeprecateFunction(func, deprecated=copy.deepcopy(self))
def resolve_argsmap(self, call):
"""
For every keyword in argsmap ensure there is a corresponding
positional where appropriate.
@deprecated_arg('name', 'age')
def set_user(name, *, age): pass
Unresolved argsmap: {'name': None, 'age': None}
Resolved argsmap: {'name': None, 0: None, 'age': None}
"""
if not self._argsmap or callable(self._argsmap):
return
sig = inspect.signature(call)
pos_parameters = []
for p in sig.parameters.values():
if p.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD):
pos_parameters.append(p)
else:
break
missing = {}
for a, v in self._argsmap.items():
if isinstance(a, int):
if a >= len(pos_parameters):
continue
name = pos_parameters[a].name
if name not in self._argsmap:
missing[name] = v
else:
if a not in sig.parameters:
continue
try:
index = pos_parameters.index(sig.parameters[a])
if index not in self._argsmap:
missing[index] = v
except ValueError:
continue
self._argsmap.update(missing)
def map_arguments(self, args: Sequence, kwargs: Dict[str, Any]):
reason, argsmap, warn = self.reason, self.argsmap, self.warn
if not argsmap:
return args, kwargs
elif callable(argsmap):
return argsmap(args, kwargs)
mapped_args, mapped_kwargs = [], {}
stacklevel = 3
def map(frm, to, arg, stacklevel):
stacklevel += 1
if callable(to):
mappings = to(arg)
for to, arg in mappings.items():
map(frm, to, arg, stacklevel)
return
elif isinstance(frm, int) and to is None:
message = reason.FUNC_POSARG_DEPRECATED.format(
deprecated=frm)
to = frm
elif to is None:
message = reason.FUNC_KWARG_DEPRECATED.format(
deprecated=frm)
to = frm
elif isinstance(frm, int):
message = reason.FUNC_POSARG_OBSOLETE.format(obsolete=frm,
new=to)
else:
message = reason.FUNC_KWARG_OBSOLETE.format(obsolete=frm,
new=to)
warn(message=message, stacklevel=stacklevel)
if isinstance(to, int):
if to < 0:
raise TypeError(f"got negative index '{to}' "
f"for positional argument")
mapped_args.append((to, arg))
else:
if to in mapped_kwargs:
raise TypeError(f"got multiple values for argument '{to}'")
mapped_kwargs[to] = arg
for index, arg in enumerate(args):
if index in argsmap:
map(index, argsmap[index], arg, stacklevel)
else:
mapped_args.append((index, arg))
for key, arg in kwargs.items():
if key in argsmap:
map(key, argsmap[key], arg, stacklevel)
else:
if key in mapped_kwargs:
raise TypeError(f"got multiple values for argument '{key}'")
mapped_kwargs[key] = arg
# Due to misuse mapped_args may contain
# gaps and duplicates. This is an error.
mapped_args.sort(key=lambda x: x[0])
last_index = -1
for index, arg in mapped_args:
if index == last_index:
raise TypeError(f"got multiple values for positional argument "
f"'{index + 1}'")
elif index != last_index + 1:
raise TypeError(f"missing positional argument "
f"'{last_index + 2}'")
last_index = index
return (a for i, a in mapped_args), mapped_kwargs
def update(self, other: 'Deprecated'):
self._reason = other._reason or self._reason
self._new = other._new or self._new
self._warn = other._warn or self._warn
self._allow_usage = other._allow_usage and self._allow_usage
if self._argsmap and other._argsmap:
self._argsmap.update(other._argsmap)
else:
self._argsmap = other._argsmap or self._argsmap
def __call__(self, *args, **kwargs):
if len(args) == 1:
target = args[0]
if hasattr(target, '__dict__') and '__deprecated__' in target.__dict__:
target.__deprecated__.update(self)
return target
elif isinstance(target, property):
return self.deprecate_property(target)
elif inspect.isclass(target):
return self.deprecate_class(target)
elif callable(target):
return self.deprecate_function(target)
return type(self)(*args, **kwargs)
def __enter__(self):
self.warn(message=self.reason.CM_DEPRECATED)
return
def __exit__(self, *args, **kwargs):
return
deprecated = Deprecated()
def obsolete(new, reason=None, argsmap=None, warn=None):
return Deprecated(reason, new, argsmap, warn, False)
def deprecated_arg(*args: Sequence[ArgsMapKeyT]) -> Deprecated:
return Deprecated(argsmap={a: None for a in args}, allow_usage=True)
@overload
def obsolete_arg(**kwargs: ArgsMapValueT) -> Deprecated:
pass
@overload
def obsolete_arg(mapping: ArgsMappingT, **kwargs: ArgsMapValueT) -> Deprecated:
pass
def obsolete_arg(*args, **kwargs):
if len(args) > 1:
raise TypeError('obsolete_arg expected at most 1 '
f'arguments, got {len(args)}')
elif len(args) == 1:
argsmap = dict(args[0])
else:
argsmap = {}
# TODO: Raise exception if keys are overridden?
argsmap.update(kwargs)
return Deprecated(argsmap=argsmap, allow_usage=True)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment