Last active
September 14, 2018 00:04
-
-
Save Kentzo/53df97c7a54609d3febf5f8eb6b67118 to your computer and use it in GitHub Desktop.
The deprecated extension for the warnings module
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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