Source code for eradiate.util.deprecation

from __future__ import annotations

import datetime
import functools
import inspect
import typing as t
import warnings

from packaging import version

from .numpydoc import format_doc, parse_doc
from .._version import _version

# Note: Most of this code is taken from https://github.com/briancurtin/deprecation.
# Class wrapping code is inspired by https://github.com/tantale/deprecated.


[docs] def deprecated( deprecated_in: str | None = None, removed_in: str | None = None, details: str = "", current_version: str = None, ): r""" Decorate a component to signify its deprecation This component wraps a component (function or class) that will soon be removed and does two things: * The docstring of the method will be modified to include a notice about deprecation, *e.g.* "Deprecated since 0.9.11. Use foo instead." * Raises a :class:`.DeprecatedWarning` via the :mod:`warnings` module, which is a subclass of the built-in :class:`DeprecationWarning`. Note that built-in :class:`DeprecationWarning`\ s are ignored by default, so for users to be informed of said warnings they will need to enable them---see the :mod:`warnings` module documentation for more details. Parameters ---------- deprecated_in : str, optional, default: None The version at which the decorated method is considered deprecated. This will usually be the next version to be released when the decorator is added. The default is ``None``, which effectively means immediate deprecation. If this is not specified, then the `removed_in` and `current_version` arguments are ignored. removed_in : str, optional, default: None The version or :class:`datetime.date` when the decorated method will be removed. The default is ``None``, specifying that the component is not currently planned to be removed. Note: This parameter cannot be set to a value if `deprecated_in` is set to ``None``. details : str, optional Extra details to be added to the method docstring and warning. For example, the details may point users to a replacement method, such as "Use the foo_bar method instead". By default, there are no details. current_version : str, optional Current version. If unset, Eradiate's package version number is looked up automatically. Raises ------ ValueError If `removed_in` is not ``None`` and `deprecated_in` is not ``None``. Examples -------- This decorator allows for specifying the version from which the wrapped component is deprecated, and when it will be retired (this latter argument is optional): .. testsetup:: deprecated from eradiate.util.deprecation import deprecated .. doctest:: deprecated >>> @deprecated(deprecated_in="0.21.1", removed_in="0.22.1") ... def foo(): ... >>> foo() This will emit a :class:`.UnsupportedWarning`, which you can then handle. We can also deprecate classes: .. doctest:: deprecated >>> @deprecated(deprecated_in="0.21.1", removed_in="0.22.1") ... class SomeClass: ... If those components are used before v0.21.1, nothing happens. If they are used between v0.21.1 (included) and v0.22.1 (excluded), a :class:`.DeprecatedWarning` is emitted. If they are used from v0.22.1 on, an :class:`.UnsupportedWarning` is emitted. When used with static or class methods, their respective decorators must be applied *after* this one: .. doctest:: deprecated >>> class SomeClass: ... @staticmethod ... @deprecated(deprecated_in="0.21.1", removed_in="0.22.1") ... def staticmethod(): ... ... ... @classmethod ... @deprecated(deprecated_in="0.21.1", removed_in="0.22.1") ... def classmethod(cls): ... .. testcleanup:: deprecated del deprecated """ # You can't just jump to removal. It's weird, unfair, and also makes # building up the docstring weird. if deprecated_in is None and removed_in is not None: raise ValueError( "Cannot set removed_in to a value without also setting deprecated_in" ) # Only warn when it's appropriate. There may be cases when it makes sense # to add this decorator before a formal deprecation period begins. # In CPython, PendingDeprecatedWarning gets used in that period, # so perhaps mimic that at some point. is_deprecated = False is_unsupported = False # StrictVersion won't take a None or a "", so make whatever goes to it # is at least *something*. Compare versions only if removed_in is not # of type datetime.date if isinstance(removed_in, datetime.date): if datetime.date.today() >= removed_in: is_unsupported = True else: is_deprecated = True else: current_version = version.parse( _version if current_version is None else current_version ) if removed_in is not None and current_version >= version.parse(removed_in): is_unsupported = True elif deprecated_in and current_version >= version.parse(deprecated_in): is_deprecated = True should_warn = any([is_deprecated, is_unsupported]) def _wrapper(wrapped): if should_warn: # The various parts of this decorator being optional makes for # a number of ways the deprecation notice could go. The following # makes for a nicely constructed sentence with or without any # of the parts. # If removed_in is a date, use "removed on" # If removed_in is a version, use "removed in" parts = [".. deprecated::"] if deprecated_in: parts.append(f" {deprecated_in}") if removed_in: parts.append( "\n This will be removed " f"{'on' if isinstance(removed_in, datetime.date) else 'in'} " f"{removed_in}." ) if details: parts.append(f"\n {details}") deprecation_note = "".join(parts) # Parse and update wrapped object docstring doc = wrapped.__doc__ or "" # Safety in case wrapped has no docstring sections = parse_doc(doc) sections["_deprecation"] = deprecation_note wrapped.__doc__ = "".join(format_doc(sections)) # Define the actual emitted warning warning_cls = UnsupportedWarning if is_unsupported else DeprecatedWarning the_warning = warning_cls( wrapped.__name__, deprecated_in, removed_in, details ) if inspect.isclass( wrapped ): # We're wrapping a class: issue a warning upon class instantiation old_new = wrapped.__new__ def wrapped_cls(cls, *args, **kwargs): if should_warn: warnings.warn( the_warning, category=DeprecationWarning, stacklevel=2 ) if old_new is object.__new__: return old_new(cls) else: # actually, we don't know the real signature of *old_new* return old_new(cls, *args, **kwargs) wrapped.__new__ = staticmethod(wrapped_cls) return wrapped else: # We assume we're wrapping a function: issue a warning upon call @functools.wraps(wrapped) def _inner(*args, **kwargs): if should_warn: warnings.warn( the_warning, category=DeprecationWarning, stacklevel=2 ) return wrapped(*args, **kwargs) return _inner return _wrapper
[docs] def substitute(subs: dict[str, tuple[type, dict[str, str]]]) -> t.Callable: """ Generate a simple module :func:`__getattr__` which redirects outdated attribute lookups to current values with a deprecation warning. Parameters ---------- subs: dict A dictionary with outdated names as keys. Values are 2-tuples consisting of the current substitute and a dictionary of keyword arguments passed to the :class:`DeprecatedWarning` constructor. Returns ------- callable The generated module :func:`__getattr__` function. Example ------- >>> __getattr__ = substitute( ... { ... "OneDimExperiment": ( # Old name ... AtmosphereExperiment, # New type ... {"deprecated_in": "0.22.5", "removed_in": "0.22.7"}, # Keyword args ... ) ... } ... ) """ def __getattr__(name): if name in subs: new_type, kwargs = subs[name] warnings.warn( DeprecatedWarning( component=f"'{name}'", details=f"Use '{new_type.__name__}' instead.", **kwargs, ), ) return new_type raise AttributeError(f"module '{__name__}' has no attribute '{name}'") return __getattr__
[docs] class DeprecatedWarning(DeprecationWarning): """ A warning class for deprecated methods This is a specialization of the built-in :class:`DeprecationWarning`, adding parameters that allow us to get information into the ``__str__`` that ends up being sent through the :mod:`warnings` system. The attributes aren't able to be retrieved after the warning gets raised and passed through the system as only the class--not the instance--and message are what gets preserved. Parameters ---------- component : type or callable The component being deprecated. deprecated_in : str, optional, default: None The version that `component` is deprecated in. removed_in : str or datetime, optional, default: None The version or :class:`datetime.date` specifying when `component` gets removed. details : str, optional Optional details about the deprecation. Most often this will include directions on what to use instead of the now deprecated code. """ def __init__(self, component, deprecated_in, removed_in, details=""): # NOTE: The docstring only works for this class if it appears up # near the class name, not here inside __init__. I think it has # to do with being an exception class. self.component = component self.deprecated_in = deprecated_in self.removed_in = removed_in self.details = details super(DeprecatedWarning, self).__init__( component, deprecated_in, removed_in, details ) def __str__(self): parts = [f"{self.component} is deprecated"] if self.deprecated_in: parts.append(f" as of {self.deprecated_in}") if self.removed_in: parts.append( " and will be removed " f"{'on' if isinstance(self.removed_in, datetime.date) else 'in'} " f"{self.removed_in}" ) if any([self.deprecated_in, self.removed_in, self.details]): parts.append(".") if self.details: parts.append(f" {self.details}") return "".join(parts)
[docs] class UnsupportedWarning(DeprecatedWarning): """ A warning class for methods to be removed This is a subclass of :class:`.DeprecatedWarning` and is used to output a proper message about a component being unsupported. """ def __str__(self): parts = [f"{self.component} is unsupported as of {self.removed_in}."] if self.details: parts.append(f" {self.details}") return "".join(parts)