Source code for glotaran.deprecation.deprecation_utils

"""Helper functions to give deprecation warnings."""

from __future__ import annotations

import os
import re
import sys
from collections.abc import Callable
from collections.abc import Hashable
from collections.abc import Mapping
from collections.abc import MutableMapping
from functools import wraps
from importlib import import_module
from importlib.metadata import distribution
from types import ModuleType
from typing import TYPE_CHECKING
from typing import Any
from typing import TypeVar
from typing import cast
from warnings import warn

import numpy as np

DecoratedCallable = TypeVar(
    "DecoratedCallable", bound=Callable[..., Any]
)  # decorated function or class

if TYPE_CHECKING:
    from collections.abc import Sequence
    from typing import NoReturn


[docs] class OverDueDeprecation(Exception): """Error thrown when a deprecation should have been removed. See Also -------- deprecate warn_deprecated deprecate_module_attribute deprecate_submodule deprecate_dict_entry """
[docs] class GlotaranDeprecatedApiError(Exception): """Exception raised when a deprecation has no replacement. See Also -------- deprecate warn_deprecated deprecate_module_attribute deprecate_submodule deprecate_dict_entry """
[docs] class GlotaranApiDeprecationWarning(UserWarning): """Warning to give users about API changes. See Also -------- deprecate warn_deprecated deprecate_module_attribute deprecate_submodule deprecate_dict_entry """
[docs] def glotaran_version() -> str: """Version of the distribution. This is basically the same as ``glotaran.__version__`` but independent from glotaran. This way all of the deprecation functionality can be used even in ``glotaran.__init__.py`` without moving the import below the definition of ``__version__`` or causing a circular import issue. Returns ------- str The version string. """ return distribution("pyglotaran").version
[docs] def parse_version(version_str: str) -> tuple[int, int, int]: """Parse version string to tuple of three ints for comparison. Parameters ---------- version_str : str Fully qualified version string of the form 'major.minor.patch'. Returns ------- tuple[int, int, int] Version as tuple. Raises ------ ValueError If ``version_str`` has less that three elements separated by ``.``. ValueError If ``version_str`` 's first three elements can not be casted to int. """ error_message = ( "version_str needs to be a fully qualified version consisting of " f"int parts (e.g. '0.0.1'), got {version_str!r}" ) split_version = version_str.partition("-")[0].split(".") if len(split_version) < 3: raise ValueError(error_message) try: return tuple( map(int, (*split_version[:2], split_version[2].partition("rc")[0])) ) # type:ignore[return-value] except ValueError as err: raise ValueError(error_message) from err
[docs] def check_qualnames_in_tests(qual_names: Sequence[str], importable_indices: Sequence[int]): """Test that qualnames import path exists when running tests. All deprecations should be tested anyway in order to get the proper errors when a deprecation is overdue. This helperfunction also helps to ensure that at least the import paths (``qual_names``) of the old and new usage exist. Parameters ---------- qual_names : Sequence[str] Sequence of fully qualified module attribute names, optionally with call arguments. importable_indices: Sequence[int] Indices of corresponding to ``qual_names`` indicating how to slice each ``qual_name`` split at ``.``, for the import and attribute checking. See Also -------- warn_deprecated deprecate """ # Since this is always true for tests run with pytest we ignore the branch coverage if "PYTEST_CURRENT_TEST" in os.environ: # pragma: no branch for qual_name, slice_index in zip(qual_names, importable_indices): qual_name_parts = qual_name.partition("(")[0].partition("[")[0].split(".") module_name = ".".join(qual_name_parts[:-slice_index]) object_name = qual_name_parts[-slice_index] module = __import__(module_name, fromlist=(object_name)) assert hasattr(module, object_name) if slice_index != 1: item = getattr(module, object_name) hasattr(item, qual_name_parts[-slice_index + 1])
[docs] def check_overdue(deprecated_qual_name_usage: str, to_be_removed_in_version: str) -> None: """Check if a deprecation is overdue for removal. Parameters ---------- deprecated_qual_name_usage : str Old usage with fully qualified name e.g.: ``'glotaran.read_model_from_yaml(model_yml_str)'`` to_be_removed_in_version : str Version the support for this usage will be removed. Raises ------ OverDueDeprecation If the current version is greater or equal to ``to_be_removed_in_version``. """ if ( parse_version(glotaran_version()) >= parse_version(to_be_removed_in_version) and "dev" not in glotaran_version() ): raise OverDueDeprecation( f"Support for {deprecated_qual_name_usage.partition('(')[0]!r} was " f"supposed to be dropped in version: {to_be_removed_in_version!r}.\n" f"Current version is: {glotaran_version()!r}" )
[docs] def raise_deprecation_error( *, deprecated_qual_name_usage: str, new_qual_name_usage: str, to_be_removed_in_version: str, ) -> NoReturn: """Raise :class:`GlotaranDeprectedApiError` error, with formatted message. This should only be used if there is no reasonable way to keep the deprecated usage functional! Parameters ---------- deprecated_qual_name_usage : str Old usage with fully qualified name e.g.: ``'glotaran.read_model_from_yaml(model_yml_str)'`` new_qual_name_usage : str New usage as fully qualified name e.g.: ``'glotaran.io.load_model(model_yml_str, format_name="yml_str")'`` to_be_removed_in_version : str Version the support for this usage will be removed. Raises ------ OverDueDeprecation If the current version is greater or equal to ``to_be_removed_in_version``. GlotaranDeprecatedApiError If :class:`OverDueDeprecation` wasn't raised before. .. # noqa: DAR402 OverDueDeprecation .. # noqa: DAR401 GlotaranDeprecatedApiError """ check_overdue(deprecated_qual_name_usage, to_be_removed_in_version) message = ( f"Usage of {deprecated_qual_name_usage!r} was deprecated, " f"use {new_qual_name_usage!r} instead.\n" "It wasn't possible to restore the original behavior of this usage " "(most likely due to an object hierarchy change)." "This usage change message won't be show as of version: " f"{to_be_removed_in_version!r}." ) raise GlotaranDeprecatedApiError(message)
[docs] def warn_deprecated( *, deprecated_qual_name_usage: str, new_qual_name_usage: str, to_be_removed_in_version: str, check_qual_names: tuple[bool, bool] = (True, True), stacklevel: int = 2, importable_indices: tuple[int, int] = (1, 1), ) -> None: """Raise deprecation warning with change information. The change information are old / new usage information and end of support version. Parameters ---------- deprecated_qual_name_usage : str Old usage with fully qualified name e.g.: ``'glotaran.read_model_from_yaml(model_yml_str)'`` new_qual_name_usage : str New usage as fully qualified name e.g.: ``'glotaran.io.load_model(model_yml_str, format_name="yml_str")'`` to_be_removed_in_version : str Version the support for this usage will be removed. check_qual_names : tuple[bool, bool] Whether or not to check for the existence ``deprecated_qual_name_usage`` and ``deprecated_qual_name_usage`` * Set the first value to False to prevent infinite recursion error when changing a module attribute import. * Set the second value to False if the new usage in in a different package or there is none. stacklevel: int Stack at which the warning should be shown as raise. Default: 2 importable_indices : tuple[int, int] Indices from right for most nested item which is importable for ``deprecated_qual_name_usage`` and ``new_qual_name_usage`` after splitting at ``.``. This is used when the old or new usage is a method or mapping access. E.g. let ``deprecated_qual_name_usage`` be ``package.module.class.mapping["key"]``, then you would use ``importable_indices=(2, 1)``, this way func:`check_qualnames_in_tests` will import ``package.module.class`` and check if ``class`` has an attribute ``mapping``. Raises ------ OverDueDeprecation If the current version is greater or equal to ``to_be_removed_in_version``. See Also -------- deprecate deprecate_module_attribute deprecate_submodule check_qualnames_in_tests Examples -------- This is the way the old ``read_parameters_from_yaml_file`` could deprecated and the usage of ``load_model`` being promoted instead. .. code-block:: python :caption: glotaran/deprecation/modules/glotaran_root.py def read_parameters_from_yaml_file(model_path: str): warn_deprecated( deprecated_qual_name_usage="glotaran.read_parameters_from_yaml_file(model_path)", new_qual_name_usage="glotaran.io.load_model.load_model(model_path)", to_be_removed_in_version="0.6.0", ) return load_model(model_path) .. # noqa: DAR402 """ check_overdue(deprecated_qual_name_usage, to_be_removed_in_version) qual_names = (deprecated_qual_name_usage, new_qual_name_usage) selected_qual_names = [ qual_name for qual_name, check in zip(qual_names, check_qual_names) if check ] selected_indices = importable_indices[: len(selected_qual_names)] check_qualnames_in_tests(qual_names=selected_qual_names, importable_indices=selected_indices) warn( GlotaranApiDeprecationWarning( f"Usage of {deprecated_qual_name_usage!r} was deprecated, " f"use {new_qual_name_usage!r} instead.\n" f"This usage will be an error in version: {to_be_removed_in_version!r}." ), stacklevel=stacklevel, )
[docs] def deprecate( *, deprecated_qual_name_usage: str, new_qual_name_usage: str, to_be_removed_in_version: str, has_glotaran_replacement: bool = True, importable_indices: tuple[int, int] = (1, 1), ) -> Callable[[DecoratedCallable], DecoratedCallable]: """Decorate a function, method or class to deprecate it. This raises deprecation warning with old / new usage information and end of support version. Parameters ---------- deprecated_qual_name_usage : str Old usage with fully qualified name e.g.: ``'glotaran.read_model_from_yaml(model_yml_str)'`` new_qual_name_usage : str New usage as fully qualified name e.g.: ``'glotaran.io.load_model(model_yml_str, format_name="yml_str")'`` to_be_removed_in_version : str Version the support for this usage will be removed. has_glotaran_replacement : bool Whether or not this functionality has a replacement in core pyglotaran. This will be mapped to the second entry of ``check_qualnames`` in :func:`warn_deprecated`. importable_indices : Sequence[int] Indices from right for most nested item which is importable for ``deprecated_qual_name_usage`` and ``new_qual_name_usage`` after splitting at ``.``. This is used when the old or new usage is a method or mapping access. E.g. let ``deprecated_qual_name_usage`` be ``package.module.class.mapping["key"]``, then you would use ``importable_indices=(2, 1)``, this way func:`check_qualnames_in_tests` will import ``package.module.class`` and check if ``class`` has an attribute ``mapping``. Default Returns ------- DecoratedCallable Original function or class throwing a Deprecation warning when used. Raises ------ OverDueDeprecation If the current version is greater or equal to ``to_be_removed_in_version``. See Also -------- warn_deprecated deprecate_module_attribute deprecate_submodule check_qualnames_in_tests Examples -------- This is the way the old ``read_parameters_from_yaml_file`` was deprecated and the usage of ``load_model`` was promoted instead. .. code-block:: python :caption: glotaran/deprecation/modules/glotaran_root.py @deprecate( deprecated_qualname_usage="glotaran.read_parameters_from_yaml_file(model_path)", new_qualname_usage="glotaran.io.load_model(model_path)", to_be_removed_in_version="0.6.0", ) def read_parameters_from_yaml_file(model_path: str): return load_model(model_path) .. # noqa: DAR402 """ def inject_warn_into_call(deprecated_object: DecoratedCallable) -> DecoratedCallable: """Wrap warning into function call. Used on deprecated_object.__new__ if it's a class or else on deprecated_object. """ @wraps(deprecated_object) def inner_wrapper(*args: Any, **kwargs: Any) -> DecoratedCallable: """Wrap running the function and warning.""" warn_deprecated( deprecated_qual_name_usage=deprecated_qual_name_usage, new_qual_name_usage=new_qual_name_usage, to_be_removed_in_version=to_be_removed_in_version, stacklevel=3, check_qual_names=(True, has_glotaran_replacement), importable_indices=importable_indices, ) return deprecated_object(*args, **kwargs) return cast(DecoratedCallable, inner_wrapper) def outer_wrapper(deprecated_object: DecoratedCallable) -> DecoratedCallable: """Wrap deprecated_object of all callable kinds.""" if not isinstance(deprecated_object, type): return cast(DecoratedCallable, inject_warn_into_call(deprecated_object)) setattr( deprecated_object, "__new__", inject_warn_into_call(deprecated_object.__new__), ) return deprecated_object # type: ignore[return-value] return cast(Callable[[DecoratedCallable], DecoratedCallable], outer_wrapper)
[docs] def deprecate_dict_entry( *, dict_to_check: MutableMapping[Hashable, Any], deprecated_usage: str, new_usage: str, to_be_removed_in_version: str, swap_keys: tuple[Hashable, Hashable] | None = None, replace_rules: tuple[Mapping[Hashable, Any], Mapping[Hashable, Any]] | None = None, stacklevel: int = 3, ) -> None: """Replace dict entry inplace and warn about usage change, if present in the dict. Parameters ---------- dict_to_check : MutableMapping[Hashable, Any] Dict which should be checked. deprecated_usage : str Old usage to inform user (only used in warning). new_usage : str New usage to inform user (only used in warning). to_be_removed_in_version : str Version the support for this usage will be removed. swap_keys : tuple[Hashable, Hashable] (old_key, new_key), ``dict_to_check[new_key]`` will be assigned the value ``dict_to_check[old_key]`` and ``old_key`` will be removed from the dict. by default None replace_rules : Mapping[Hashable, tuple[Any, Any]] ({old_key: old_value}, {new_key: new_value}), If ``dict_to_check[old_key]`` has the value ``old_value``, ``dict_to_check[new_key]`` it will be set to ``new_value``. ``old_key`` will be removed from the dict if ``old_key`` and ``new_key`` aren't equal. by default None stacklevel : int Stack at which the warning should be shown as raise. , by default 3 Raises ------ ValueError If both ``swap_keys`` and ``replace_rules`` are None (default) or not None. OverDueDeprecation If the current version is greater or equal to ``to_be_removed_in_version``. See Also -------- warn_deprecated Notes ----- To prevent confusion exactly one of ``replace_rules`` and ``swap_keys`` needs to be passed. Examples -------- For readability sake the warnings won't be shown in the examples. Swapping key names: >>> dict_to_check = {"foo": 123} >>> deprecate_dict_entry( dict_to_check=dict_to_check, deprecated_usage="foo", new_usage="bar", to_be_removed_in_version="0.6.0", swap_keys=("foo", "bar") ) >>> dict_to_check {"bar": 123} Changing values: >>> dict_to_check = {"foo": 123} >>> deprecate_dict_entry( dict_to_check=dict_to_check, deprecated_usage="foo: 123", new_usage="foo: 123.0", to_be_removed_in_version="0.6.0", replace_rules=({"foo": 123}, {"foo": 123.0}) ) >>> dict_to_check {"foo": 123.0} Swapping key names AND changing values: >>> dict_to_check = {"type": "kinetic-spectrum"} >>> deprecate_dict_entry( dict_to_check=dict_to_check, deprecated_usage="type: kinectic-spectrum", new_usage="default_megacomplex: decay", to_be_removed_in_version="0.6.0", replace_rules=({"type": "kinetic-spectrum"}, {"default_megacomplex": "decay"}) ) >>> dict_to_check {"default_megacomplex": "decay"} .. # noqa: DAR402 """ dict_changed = False if not np.logical_xor(swap_keys is None, replace_rules is None): raise ValueError( "Exactly one of the parameters `swap_keys` or `replace_rules` needs to be provided." ) if swap_keys is not None and swap_keys[0] in dict_to_check: dict_changed = True dict_to_check[swap_keys[1]] = dict_to_check[swap_keys[0]] del dict_to_check[swap_keys[0]] if replace_rules is not None: old_key, old_value = next(iter(replace_rules[0].items())) new_key, new_value = next(iter(replace_rules[1].items())) if old_key in dict_to_check and dict_to_check[old_key] == old_value: dict_changed = True dict_to_check[new_key] = new_value if new_key != old_key: del dict_to_check[old_key] if dict_changed: warn_deprecated( deprecated_qual_name_usage=deprecated_usage, new_qual_name_usage=new_usage, to_be_removed_in_version=to_be_removed_in_version, stacklevel=stacklevel, check_qual_names=(False, False), )
[docs] def module_attribute(module_qual_name: str, attribute_name: str) -> Any: """Import and return the attribute (e.g. function or class) of a module. This is basically the same as ``from module_name import attribute_name as return_value`` where this function returns ``return_value``. Parameters ---------- module_qual_name : str Fully qualified name for a module e.g. ``glotaran.model.base_model`` attribute_name : str Name of the attribute e.g. ``Model`` Returns ------- Any Attribute of the module, e.g. a function or class. """ module = import_module(module_qual_name) return getattr(module, attribute_name)
[docs] def deprecate_module_attribute( *, deprecated_qual_name: str, new_qual_name: str, to_be_removed_in_version: str, module_load_overwrite: str = "", ) -> Any: """Import and return and anttribute from the new location. This needs to be wrapped in the definition of a module wide ``__getattr__`` function so it won't throw warnings all the time (see example). Parameters ---------- deprecated_qual_name : str Fully qualified name of the deprecated attribute e.g.: ``glotaran.ParameterGroup`` new_qual_name : str Fully qualified name of the new attribute e.g.: ``glotaran.parameter.ParameterGroup`` to_be_removed_in_version : str Version the support for this usage will be removed. module_load_overwrite : str Overwrite the location the functionality will be set from. This allows preserving functionality without polluting a new module with code just for the sake of it. By default '' Returns ------- Any Module attribute from its new location. Raises ------ OverDueDeprecation If the current version is greater or equal to ``to_be_removed_in_version``. See Also -------- deprecate warn_deprecated deprecate_submodule Examples -------- When deprecating the usage of ``ParameterGroup`` the root of ``glotaran`` and promoting to import it from ``glotaran.parameter`` the following code was added to the root ``__init__.py``. .. code-block:: python :caption: glotaran/__init__.py def __getattr__(attribute_name: str): from glotaran.deprecation import deprecate_module_attribute if attribute_name == "ParameterGroup": return deprecate_module_attribute( deprecated_qual_name="glotaran.ParameterGroup", new_qual_name="glotaran.parameter.ParameterGroup", to_be_removed_in_version="0.6.0", ) raise AttributeError(f"module {__name__} has no attribute {attribute_name}") .. # noqa: DAR402 """ if not module_load_overwrite: module_name = ".".join(new_qual_name.split(".")[:-1]) attribute_name = new_qual_name.split(".")[-1] check_qual_names = (False, True) else: module_name = ".".join(module_load_overwrite.split(".")[:-1]) attribute_name = module_load_overwrite.split(".")[-1] check_qual_names = (False, False) warn_deprecated( deprecated_qual_name_usage=re.sub(r"\.__path__$", "", deprecated_qual_name), new_qual_name_usage=re.sub(r"\.__path__$", "", new_qual_name), to_be_removed_in_version=to_be_removed_in_version, check_qual_names=check_qual_names, stacklevel=4, importable_indices=(1, 1), ) return module_attribute(module_name, attribute_name)
[docs] def deprecate_submodule( *, deprecated_module_name: str, new_module_name: str, to_be_removed_in_version: str, module_load_overwrite: str = "", ) -> ModuleType: r"""Create a module at runtime which retrieves attributes from new module. When moving a module, create a variable with the modules name in the parent packages ``__init__.py``, so imports will be redirected to the new module location and a deprecation warning will be given, to help the user adjust the outdated code. Each time an attribute is retrieved there will be a deprecation warning. Parameters ---------- deprecated_module_name : str Fully qualified name of the deprecated module e.g.: ``'glotaran.analysis.result'`` new_module_name : str Fully qualified name of the new module e.g.: ``'glotaran.project.result'`` to_be_removed_in_version : str Version the support for this usage will be removed. module_load_overwrite : str Overwrite the location for the new module the deprecated functionality is loaded from. This allows preserving functionality without polluting a new module with code just for the sake of it. By default '' Returns ------- ModuleType Module containing Raises ------ OverDueDeprecation If the current version is greater or equal to ``to_be_removed_in_version``. See Also -------- deprecate deprecate_module_attribute Examples -------- When moving the module ``result`` from ``glotaran.analysis.result`` to ``glotaran.project.result`` the following code was added to the old parent packages (``glotaran.analysis``) ``__init__.py``. .. code-block:: python :caption: glotaran/analysis/__init__.py from glotaran.deprecation.deprecation_utils import deprecate_submodule result = deprecate_submodule( deprecated_module_name="glotaran.analysis.result", new_module_name="glotaran.project.result", to_be_removed_in_version="0.6.0", ) .. # noqa: DAR402 """ new_module = ( import_module(module_load_overwrite) if module_load_overwrite else import_module(new_module_name) ) deprecated_module = ModuleType( deprecated_module_name, f"Deprecated use {new_module_name!r} instead.\n\n{new_module.__doc__}", ) def warn_getattr(attribute_name: str): if attribute_name == "__file__": return new_module.__file__ elif attribute_name in dir(new_module): return deprecate_module_attribute( deprecated_qual_name=f"{deprecated_module_name}.{attribute_name}", new_qual_name=f"{new_module_name}.{attribute_name}", to_be_removed_in_version=to_be_removed_in_version, module_load_overwrite=module_load_overwrite and f"{module_load_overwrite}.{attribute_name}", ) raise AttributeError(f"module {deprecated_module_name} has no attribute {attribute_name}") setattr(deprecated_module, "__getattr__", warn_getattr) setattr(deprecated_module, "__package__", deprecated_module_name.split(".")[:-1]) setattr(deprecated_module, "__dir__", new_module.__dir__) sys.modules[deprecated_module_name] = deprecated_module return deprecated_module