diff --git a/docs/api.md b/docs/api.md index 3c8d5ab..edd2cd6 100644 --- a/docs/api.md +++ b/docs/api.md @@ -20,3 +20,12 @@ Types used by the former: ExtensionNamespace ``` + +## Deprecations +``` {eval-rst} +.. autosummary:: + :toctree: generated + + deprecated + Deprecation +``` diff --git a/pyproject.toml b/pyproject.toml index d33cb47..853b925 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,9 +13,10 @@ maintainers = [ authors = [ { name = "Ilia Kats" }, ] -requires-python = ">=3.11" +requires-python = ">=3.10" classifiers = [ "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", @@ -25,6 +26,7 @@ dynamic = [ "version" ] dependencies = [ # for debug logging (referenced from the issue template) "session-info2", + "typing-extensions; python_version<'3.13'", ] # https://docs.pypi.org/project_metadata/#project-urls urls.Documentation = "https://scverse-misc.readthedocs.io/" @@ -63,7 +65,7 @@ envs.docs.scripts.clean = "git clean -fdX -- {args:docs}" envs.hatch-test.dependency-groups = [ "dev", "test" ] envs.hatch-test.matrix = [ # Test the lowest and highest supported Python versions with normal deps - { deps = [ "stable" ], python = [ "3.11", "3.14" ] }, + { deps = [ "stable" ], python = [ "3.10", "3.14" ] }, # Test the newest supported Python version also with pre-release deps { deps = [ "pre" ], python = [ "3.14" ] }, ] diff --git a/src/scverse_misc/__init__.py b/src/scverse_misc/__init__.py index a245d87..5f5e3cc 100644 --- a/src/scverse_misc/__init__.py +++ b/src/scverse_misc/__init__.py @@ -1,2 +1,3 @@ +from ._deprecated import Deprecation, deprecated from ._extensions import ExtensionNamespace, make_register_namespace_decorator from ._version import __version__, __version_tuple__ diff --git a/src/scverse_misc/_deprecated.py b/src/scverse_misc/_deprecated.py new file mode 100644 index 0000000..aba90e2 --- /dev/null +++ b/src/scverse_misc/_deprecated.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import sys +from inspect import getdoc +from typing import TYPE_CHECKING, TypeVar + +if sys.version_info >= (3, 11): + from typing import LiteralString +else: + from typing_extensions import LiteralString + +if sys.version_info >= (3, 13): + from warnings import deprecated as _deprecated +else: + from typing_extensions import deprecated as _deprecated + +if TYPE_CHECKING: + from collections.abc import Callable + + F = TypeVar("F", bound=Callable) + + +class Deprecation(str): + """Utility class storing information on deprecated functionality. + + Args: + version_deprecated: The version of the package where the functionality was deprecated. + msg: The deprecation message. + """ + + def __new__(cls, version_deprecated: LiteralString, msg: LiteralString = "") -> LiteralString: + obj = super().__new__(cls, msg) + obj.version_deprecated = version_deprecated + return obj + + +def _deprecated_at(msg: Deprecation, *, category=FutureWarning, stacklevel=1) -> Callable[[F], F]: + """Decorator to indicate that a class, function, or overload is deprecated. + + Wraps :func:`warnings.deprecated` and additionally modifies the docstring to include a deprecation notice. + + Args: + msg: The deprecation message. + category: The category of the warning that will be emitted at runtime. + stacklevel: The stack level of the warning. + + Examples: + >>> @deprecated(Deprecation("0.2", "Use bar() instead.")) + ... def foo(baz): + ... pass + """ + + def decorate(func: F) -> F: + kind = "function" if func.__name__ == func.__qualname__ else "method" + warnmsg = f"The {kind} {func.__name__} is deprecated and will be removed in the future." + + doc = getdoc(func) + docmsg = f".. version-deprecated:: {msg.version_deprecated}" + if len(msg) is not None: + docmsg += f"\n {msg}" + warnmsg += f" {msg}" + + if doc is None: + doc = docmsg + else: + lines = doc.splitlines() + body = "\n".join(lines[1:]) + doc = f"{lines[0]}\n\n{docmsg}\n{body}" + func.__doc__ = doc + + return _deprecated(warnmsg, category=category, stacklevel=stacklevel)(func) + + return decorate + + +if TYPE_CHECKING: + deprecated = _deprecated +else: + deprecated = _deprecated_at diff --git a/tests/test_deprecation_decorator.py b/tests/test_deprecation_decorator.py new file mode 100644 index 0000000..1d7e931 --- /dev/null +++ b/tests/test_deprecation_decorator.py @@ -0,0 +1,54 @@ +import pytest + +from scverse_misc import Deprecation, deprecated + + +@pytest.fixture(params=[None, "Test message."]) +def msg(request: pytest.FixtureRequest): + return request.param + + +@pytest.fixture( + params=[ + None, + "Test function", + """Test function + + This is a test. + + Parameters + ---------- + foo + bar + bar + baz +""", + ] +) +def docstring(request): + return request.param + + +@pytest.fixture +def deprecated_func(msg, docstring): + def func(foo, bar): + return 42 + + func.__doc__ = docstring + return deprecated(Deprecation("foo", msg))(func) + + +def test_deprecation_decorator(deprecated_func, docstring, msg): + with pytest.warns(FutureWarning, match="deprecated"): + assert deprecated_func(1, 2) == 42 + + lines = deprecated_func.__doc__.expandtabs().splitlines() + if docstring is None: + assert lines[0].startswith(".. version-deprecated::") + else: + lines_orig = docstring.expandtabs().splitlines() + assert lines[0] == lines_orig[0] + assert len(lines[1].strip()) == 0 + assert lines[2].startswith(".. version-deprecated") + if msg is not None: + assert lines[3] == f" {msg}"