From c1323f87c0d421b5ac1137ac0297e66c3d99c938 Mon Sep 17 00:00:00 2001 From: Ilia Kats Date: Thu, 9 Apr 2026 12:07:19 +0200 Subject: [PATCH 1/5] add a deprecation decorator see discussion in https://github.com/scverse/mudata/pull/131 --- docs/api.md | 9 +++ pyproject.toml | 4 +- src/scverse_misc/__init__.py | 1 + src/scverse_misc/_deprecated.py | 85 +++++++++++++++++++++++++++++ tests/test_deprecation_decorator.py | 59 ++++++++++++++++++++ 5 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 src/scverse_misc/_deprecated.py create mode 100644 tests/test_deprecation_decorator.py 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..ffc9187 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/" 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..488a227 --- /dev/null +++ b/src/scverse_misc/_deprecated.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import sys +from contextlib import suppress +from dataclasses import dataclass +from typing import TYPE_CHECKING, TypeVar + +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) + + +@dataclass(frozen=True) +class Deprecation: + """Utility class storing information on deprecated functionality.""" + + version_deprecated: str + """The version of the package where the functionality was deprecated.""" + + msg: str | None = None + """The deprecation message.""" + + +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("Use bar() instead.", "0.2")) + ... def foo(baz): + ... pass + """ + + def decorate(func: F) -> F: + if func.__name__ == func.__qualname__: + warnmsg = f"The function {func.__name__} is deprecated and will be removed in the future." + else: + warnmsg = f"The method {func.__qualname__} is deprecated and will be removed in the future." + + doc = func.__doc__ + indentation = "" + if doc is not None: + lines = doc.expandtabs().splitlines() + with suppress(StopIteration): + for line in lines[1:]: + if not len(line): + continue + for indentation, char in enumerate(line): + if not char.isspace(): + indentation = line[:indentation] + raise StopIteration # break out of both loops + + docmsg = f"{indentation}.. version-deprecated:: {msg.version_deprecated}" + if msg.msg is not None: + docmsg += f"\n{indentation} {msg.msg}" + warnmsg += f" {msg.msg}" + + if doc is None: + doc = docmsg + else: + 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..0c29f3a --- /dev/null +++ b/tests/test_deprecation_decorator.py @@ -0,0 +1,59 @@ +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 + if len(lines_orig) == 1: + assert lines[2].startswith(".. version-deprecated") + if msg is not None: + assert lines[3] == f" {msg}" + else: + assert lines[2].startswith(" .. version-deprecated") + if msg is not None: + assert lines[3] == f" {msg}" From 794ef3428eb7c9dee1606aaa32bc6df4d3cd9a4f Mon Sep 17 00:00:00 2001 From: Ilia Kats Date: Thu, 9 Apr 2026 17:04:29 +0200 Subject: [PATCH 2/5] typecheck-compatible version --- src/scverse_misc/_deprecated.py | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/src/scverse_misc/_deprecated.py b/src/scverse_misc/_deprecated.py index 488a227..ee83de0 100644 --- a/src/scverse_misc/_deprecated.py +++ b/src/scverse_misc/_deprecated.py @@ -2,8 +2,7 @@ import sys from contextlib import suppress -from dataclasses import dataclass -from typing import TYPE_CHECKING, TypeVar +from typing import TYPE_CHECKING, LiteralString, TypeVar if sys.version_info >= (3, 13): from warnings import deprecated as _deprecated @@ -16,15 +15,18 @@ F = TypeVar("F", bound=Callable) -@dataclass(frozen=True) -class Deprecation: - """Utility class storing information on deprecated functionality.""" +class Deprecation(str): + """Utility class storing information on deprecated functionality. - version_deprecated: str - """The version of the package where the functionality was deprecated.""" + Args: + version_deprecated: The version of the package where the functionality was deprecated. + msg: The deprecation message. + """ - msg: str | None = None - """The deprecation message.""" + def __new__(cls, version_deprecated: str, msg: str = "") -> 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]: @@ -38,7 +40,7 @@ def _deprecated_at(msg: Deprecation, *, category=FutureWarning, stacklevel=1) -> stacklevel: The stack level of the warning. Examples: - >>> @deprecated(Deprecation("Use bar() instead.", "0.2")) + >>> @deprecated(Deprecation("0.2", "Use bar() instead.")) ... def foo(baz): ... pass """ @@ -63,9 +65,9 @@ def decorate(func: F) -> F: raise StopIteration # break out of both loops docmsg = f"{indentation}.. version-deprecated:: {msg.version_deprecated}" - if msg.msg is not None: - docmsg += f"\n{indentation} {msg.msg}" - warnmsg += f" {msg.msg}" + if len(msg) is not None: + docmsg += f"\n{indentation} {msg}" + warnmsg += f" {msg}" if doc is None: doc = docmsg From e19dfc0d3268a88cf0d31029fd5ba8c433a5d1bb Mon Sep 17 00:00:00 2001 From: Ilia Kats Date: Fri, 10 Apr 2026 12:03:32 +0200 Subject: [PATCH 3/5] apply suggestions from code review - run tests on Python 3.10 - simplify warning message generation - LiteralString type hints --- pyproject.toml | 2 +- src/scverse_misc/_deprecated.py | 8 +++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ffc9187..853b925 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,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/_deprecated.py b/src/scverse_misc/_deprecated.py index ee83de0..c5ad353 100644 --- a/src/scverse_misc/_deprecated.py +++ b/src/scverse_misc/_deprecated.py @@ -23,7 +23,7 @@ class Deprecation(str): msg: The deprecation message. """ - def __new__(cls, version_deprecated: str, msg: str = "") -> LiteralString: + def __new__(cls, version_deprecated: LiteralString, msg: LiteralString = "") -> LiteralString: obj = super().__new__(cls, msg) obj.version_deprecated = version_deprecated return obj @@ -46,10 +46,8 @@ def _deprecated_at(msg: Deprecation, *, category=FutureWarning, stacklevel=1) -> """ def decorate(func: F) -> F: - if func.__name__ == func.__qualname__: - warnmsg = f"The function {func.__name__} is deprecated and will be removed in the future." - else: - warnmsg = f"The method {func.__qualname__} is deprecated and will be removed in the future." + 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 = func.__doc__ indentation = "" From b91867067eab97b68a27fad5bb193cf94a898796 Mon Sep 17 00:00:00 2001 From: Ilia Kats Date: Fri, 10 Apr 2026 12:08:14 +0200 Subject: [PATCH 4/5] fix for Python 3.10 --- src/scverse_misc/_deprecated.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/scverse_misc/_deprecated.py b/src/scverse_misc/_deprecated.py index c5ad353..1593ef2 100644 --- a/src/scverse_misc/_deprecated.py +++ b/src/scverse_misc/_deprecated.py @@ -2,7 +2,12 @@ import sys from contextlib import suppress -from typing import TYPE_CHECKING, LiteralString, TypeVar +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 From c7720292635a02b1c542b79e355bfc195abbd2f2 Mon Sep 17 00:00:00 2001 From: Ilia Kats Date: Fri, 10 Apr 2026 16:14:49 +0200 Subject: [PATCH 5/5] switch to inspect.getdoc instead of processing docstrings manually --- src/scverse_misc/_deprecated.py | 21 +++++---------------- tests/test_deprecation_decorator.py | 11 +++-------- 2 files changed, 8 insertions(+), 24 deletions(-) diff --git a/src/scverse_misc/_deprecated.py b/src/scverse_misc/_deprecated.py index 1593ef2..aba90e2 100644 --- a/src/scverse_misc/_deprecated.py +++ b/src/scverse_misc/_deprecated.py @@ -1,7 +1,7 @@ from __future__ import annotations import sys -from contextlib import suppress +from inspect import getdoc from typing import TYPE_CHECKING, TypeVar if sys.version_info >= (3, 11): @@ -54,27 +54,16 @@ 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 = func.__doc__ - indentation = "" - if doc is not None: - lines = doc.expandtabs().splitlines() - with suppress(StopIteration): - for line in lines[1:]: - if not len(line): - continue - for indentation, char in enumerate(line): - if not char.isspace(): - indentation = line[:indentation] - raise StopIteration # break out of both loops - - docmsg = f"{indentation}.. version-deprecated:: {msg.version_deprecated}" + doc = getdoc(func) + docmsg = f".. version-deprecated:: {msg.version_deprecated}" if len(msg) is not None: - docmsg += f"\n{indentation} {msg}" + 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 diff --git a/tests/test_deprecation_decorator.py b/tests/test_deprecation_decorator.py index 0c29f3a..1d7e931 100644 --- a/tests/test_deprecation_decorator.py +++ b/tests/test_deprecation_decorator.py @@ -49,11 +49,6 @@ def test_deprecation_decorator(deprecated_func, docstring, msg): lines_orig = docstring.expandtabs().splitlines() assert lines[0] == lines_orig[0] assert len(lines[1].strip()) == 0 - if len(lines_orig) == 1: - assert lines[2].startswith(".. version-deprecated") - if msg is not None: - assert lines[3] == f" {msg}" - else: - assert lines[2].startswith(" .. version-deprecated") - if msg is not None: - assert lines[3] == f" {msg}" + assert lines[2].startswith(".. version-deprecated") + if msg is not None: + assert lines[3] == f" {msg}"