# Copyright (C) 2012 Anaconda, Inc
# SPDX-License-Identifier: BSD-3-Clause
from __future__ import annotations
import warnings
from functools import wraps
from types import ModuleType
from typing import Any, Callable
from packaging.version import Version, parse
from . import __version__
class DeprecatedError(RuntimeError):
pass
# inspired by deprecation (https://deprecation.readthedocs.io/en/latest/) and
# CPython's warnings._deprecated
class DeprecationHandler:
_version: Version
def __init__(self, version: Version | str):
"""Factory to create a deprecation handle for the specified version.
:param version: The version to compare against when checking deprecation statuses.
"""
try:
self._version = parse(version)
except TypeError:
self._version = parse("0.0.0.dev0+placeholder")
def __call__(
self,
deprecate_in: str,
remove_in: str,
*,
addendum: str | None = None,
stack: int = 0,
) -> Callable[(Callable), Callable]:
"""Deprecation decorator for functions, methods, & classes.
:param deprecate_in: Version in which code will be marked as deprecated.
:param remove_in: Version in which code is expected to be removed.
:param addendum: Optional additional messaging. Useful to indicate what to do instead.
:param stack: Optional stacklevel increment.
"""
def deprecated_decorator(func: Callable) -> Callable:
# detect function name and generate message
category, message = self._generate_message(
deprecate_in,
remove_in,
f"{func.__module__}.{func.__qualname__}",
addendum=addendum,
)
# alert developer that it's time to remove something
if not category:
raise DeprecatedError(message)
# alert user that it's time to remove something
@wraps(func)
def inner(*args, **kwargs):
warnings.warn(message, category, stacklevel=2 + stack)
return func(*args, **kwargs)
return inner
return deprecated_decorator
def argument(
self,
deprecate_in: str,
remove_in: str,
argument: str,
*,
rename: str | None = None,
addendum: str | None = None,
stack: int = 0,
) -> Callable[(Callable), Callable]:
"""Deprecation decorator for keyword arguments.
:param deprecate_in: Version in which code will be marked as deprecated.
:param remove_in: Version in which code is expected to be removed.
:param argument: The argument to deprecate.
:param rename: Optional new argument name.
:param addendum: Optional additional messaging. Useful to indicate what to do instead.
:param stack: Optional stacklevel increment.
"""
def deprecated_decorator(func: Callable) -> Callable:
# detect function name and generate message
category, message = self._generate_message(
deprecate_in,
remove_in,
f"{func.__module__}.{func.__qualname__}({argument})",
# provide a default addendum if renaming and no addendum is provided
addendum=f"Use '{rename}' instead."
if rename and not addendum
else addendum,
)
# alert developer that it's time to remove something
if not category:
raise DeprecatedError(message)
# alert user that it's time to remove something
@wraps(func)
def inner(*args, **kwargs):
# only warn about argument deprecations if the argument is used
if argument in kwargs:
warnings.warn(message, category, stacklevel=2 + stack)
# rename argument deprecations as needed
value = kwargs.pop(argument, None)
if rename:
kwargs.setdefault(rename, value)
return func(*args, **kwargs)
return inner
return deprecated_decorator
def module(
self,
deprecate_in: str,
remove_in: str,
*,
addendum: str | None = None,
stack: int = 0,
) -> None:
"""Deprecation function for modules.
:param deprecate_in: Version in which code will be marked as deprecated.
:param remove_in: Version in which code is expected to be removed.
:param addendum: Optional additional messaging. Useful to indicate what to do instead.
:param stack: Optional stacklevel increment.
"""
self.topic(
deprecate_in=deprecate_in,
remove_in=remove_in,
topic=self._get_module(stack)[1],
addendum=addendum,
stack=2 + stack,
)
def constant(
self,
deprecate_in: str,
remove_in: str,
constant: str,
value: Any,
*,
addendum: str | None = None,
stack: int = 0,
) -> None:
"""Deprecation function for module constant/global.
:param deprecate_in: Version in which code will be marked as deprecated.
:param remove_in: Version in which code is expected to be removed.
:param constant:
:param value:
:param addendum: Optional additional messaging. Useful to indicate what to do instead.
:param stack: Optional stacklevel increment.
"""
# detect calling module
module, fullname = self._get_module(stack)
# detect function name and generate message
category, message = self._generate_message(
deprecate_in,
remove_in,
f"{fullname}.{constant}",
addendum,
)
# alert developer that it's time to remove something
if not category:
raise DeprecatedError(message)
# patch module level __getattr__ to alert user that it's time to remove something
super_getattr = getattr(module, "__getattr__", None)
def __getattr__(name: str) -> Any:
if name == constant:
warnings.warn(message, category, stacklevel=2 + stack)
return value
if super_getattr:
return super_getattr(name)
raise AttributeError(f"module '{fullname}' has no attribute '{name}'")
module.__getattr__ = __getattr__
def topic(
self,
deprecate_in: str,
remove_in: str,
*,
topic: str,
addendum: str | None = None,
stack: int = 0,
) -> None:
"""Deprecation function for a topic.
:param deprecate_in: Version in which code will be marked as deprecated.
:param remove_in: Version in which code is expected to be removed.
:param topic: The topic being deprecated.
:param addendum: Optional additional messaging. Useful to indicate what to do instead.
:param stack: Optional stacklevel increment.
"""
# detect function name and generate message
category, message = self._generate_message(
deprecate_in, remove_in, topic, addendum
)
# alert developer that it's time to remove something
if not category:
raise DeprecatedError(message)
# alert user that it's time to remove something
warnings.warn(message, category, stacklevel=2 + stack)
def _get_module(self, stack: int) -> tuple[ModuleType, str]:
"""Detect the module from which we are being called.
:param stack: The stacklevel increment.
:return: The module and module name.
"""
import inspect # expensive
try:
frame = inspect.stack()[2 + stack]
module = inspect.getmodule(frame[0])
return (module, module.__name__)
except (IndexError, AttributeError):
raise DeprecatedError("unable to determine the calling module") from None
def _generate_message(
self, deprecate_in: str, remove_in: str, prefix: str, addendum: str
) -> tuple[type[Warning] | None, str]:
"""Deprecation decorator for functions, methods, & classes.
:param deprecate_in: Version in which code will be marked as deprecated.
:param remove_in: Version in which code is expected to be removed.
:param prefix: The message prefix, usually the function name.
:param addendum: Additional messaging. Useful to indicate what to do instead.
:return: The warning category (if applicable) and the message.
"""
deprecate_version = parse(deprecate_in)
remove_version = parse(remove_in)
if self._version < deprecate_version:
category = PendingDeprecationWarning
warning = f"is pending deprecation and will be removed in {remove_in}."
elif self._version < remove_version:
category = DeprecationWarning
warning = f"is deprecated and will be removed in {remove_in}."
else:
category = None
warning = f"was slated for removal in {remove_in}."
return (
category,
" ".join(filter(None, [prefix, warning, addendum])), # message
)
deprecated = DeprecationHandler(__version__)