"""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