From 1ac1a7f8508db7dddb90a2e7f84d210bdaba084e Mon Sep 17 00:00:00 2001 From: Ilia Kats Date: Wed, 6 May 2026 14:31:24 +0200 Subject: [PATCH 1/2] add decorator for deprecating function arguments (closes #18) --- CHANGELOG.md | 7 ++ docs/api.md | 1 + src/scverse_misc/__init__.py | 4 +- src/scverse_misc/_deprecated.py | 97 +++++++++++++++++++++- tests/test_deprecation_decorator.py | 121 ++++++++++++++++++++++------ 5 files changed, 202 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b21b046..94b6758 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning][]. [keep a changelog]: https://keepachangelog.com/en/1.1.0/ [semantic versioning]: https://semver.org/spec/v2.0.0.html +## [0.0.6] + +### Added + +- A `deprecated_arg` decorator to deprecate function arguments. + ## [0.0.5] ### Added @@ -49,6 +55,7 @@ and this project adheres to [Semantic Versioning][]. - Initial release +[0.0.6]: https://github.com/scverse/scverse-misc/releases/tag/v0.0.6 [0.0.5]: https://github.com/scverse/scverse-misc/releases/tag/v0.0.5 [0.0.4]: https://github.com/scverse/scverse-misc/releases/tag/v0.0.4 [0.0.3]: https://github.com/scverse/scverse-misc/releases/tag/v0.0.3 diff --git a/docs/api.md b/docs/api.md index afdb5e2..66634d6 100644 --- a/docs/api.md +++ b/docs/api.md @@ -27,6 +27,7 @@ Types used by the former: :toctree: generated deprecated + deprecated_arg Deprecation ``` diff --git a/src/scverse_misc/__init__.py b/src/scverse_misc/__init__.py index dfca80e..fa223b9 100644 --- a/src/scverse_misc/__init__.py +++ b/src/scverse_misc/__init__.py @@ -1,9 +1,9 @@ from contextlib import suppress -from ._deprecated import Deprecation, deprecated +from ._deprecated import Deprecation, deprecated, deprecated_arg from ._extensions import ExtensionNamespace, make_register_namespace_decorator -__all__ = ["ExtensionNamespace", "make_register_namespace_decorator", "deprecated", "Deprecation"] +__all__ = ["ExtensionNamespace", "make_register_namespace_decorator", "deprecated", "deprecated_arg", "Deprecation"] with suppress(ImportError): from ._settings import Settings diff --git a/src/scverse_misc/_deprecated.py b/src/scverse_misc/_deprecated.py index c13b65f..3a5d4f8 100644 --- a/src/scverse_misc/_deprecated.py +++ b/src/scverse_misc/_deprecated.py @@ -1,8 +1,11 @@ from __future__ import annotations +import inspect import sys -from inspect import getdoc +from functools import wraps +from textwrap import indent from typing import TYPE_CHECKING, LiteralString +from warnings import warn if sys.version_info >= (3, 13): from warnings import deprecated as _deprecated @@ -13,7 +16,7 @@ from collections.abc import Callable -__all__ = ["deprecated", "Deprecation"] +__all__ = ["deprecated", "deprecated_arg", "Deprecation"] class Deprecation(str): @@ -56,7 +59,7 @@ 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) + doc = inspect.getdoc(func) docmsg = f".. version-deprecated:: {msg.version_deprecated}" if len(msg): docmsg += f"\n {msg}" @@ -79,3 +82,91 @@ def decorate(func: F) -> F: deprecated = _deprecated else: deprecated = _deprecated_at + + +def deprecated_arg[**P, R]( + arg: LiteralString, msg: Deprecation, *, category: type[Warning] = FutureWarning, stacklevel: int = 1 +) -> Callable[[Callable[P, R]], Callable[P, R]]: + """Decorator to indicate that a function argument is deprecated. + + Emits a warning when the decorated function is called with the deprecated argument and addtionally modifies the + docstring to include a deprecation notice. + + Args: + arg: The deprecated argument. + 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_arg("bar", Deprecation("0.2", "The functionality has moved to the baz() function.")) + ... def foo(baz, bar=1): + ... pass + """ + + def decorate(func: Callable[P, R]) -> Callable[P, R]: + warnmsg = f"The argument {arg} is deprecated and will be removed in the future." + doc = inspect.getdoc(func) + docmsg = f" .. version-deprecated:: {msg.version_deprecated}" + + if len(msg): + docmsg += f"\n {msg}" + warnmsg += f" {msg}" + + if doc is not None: + lines = doc.splitlines() + docstring_style = None + in_arg_section = False + in_arg_header = False + for i, line in enumerate(lines): + if in_arg_header: + in_arg_header = False + continue + elif not in_arg_section and line == "Parameters" and lines[i + 1] == "----------": + docstring_style = "numpy" + in_arg_section = True + in_arg_header = True + elif not in_arg_section and line == "Args:": + docstring_style = "google" + in_arg_section = True + docmsg = indent(docmsg, " ") + elif in_arg_section: + if docstring_style == "numpy" and line == arg: + doc = "\n".join(lines[: i + 1]) + f"\n{docmsg}\n\n" + "\n".join(lines[i + 1 :]) + break + elif docstring_style == "google" and line.startswith(prefix := f" {arg}: "): + doc = ( + "\n".join(lines[:i]) + + f"\n{prefix}\n{docmsg}\n\n {line[len(prefix) :]}\n" + + "\n".join(lines[i + 1 :]) + ) + break + elif ( + docstring_style == "numpy" + and set(line.strip()) == {"-"} + or docstring_style == "google" + and not line[0].isspace() + ): # next section, arg not documented + break + func.__doc__ = doc + + sig = inspect.signature(func) + param = sig.parameters[arg] + + @wraps(func) + def wrapped(*args: P.args, **kwargs: P.kwargs) -> R: + if ( + param.kind in (inspect.Parameter.KEYWORD_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD) + and arg in kwargs + ): + warn(warnmsg, category=category, stacklevel=stacklevel) + else: + bound = sig.bind(*args, **kwargs) + if arg in bound.arguments and bound.arguments[arg] != param.default: + warn(warnmsg, category=category, stacklevel=stacklevel) + + return func(*args, **kwargs) + + return wrapped + + return decorate diff --git a/tests/test_deprecation_decorator.py b/tests/test_deprecation_decorator.py index 9ecea5c..7a85929 100644 --- a/tests/test_deprecation_decorator.py +++ b/tests/test_deprecation_decorator.py @@ -1,9 +1,12 @@ +import inspect +import warnings from collections.abc import Callable -from typing import cast +from typing import Literal, cast, get_args import pytest +from sphinx.ext.napoleon import GoogleDocstring, NumpyDocstring # type: ignore[attr-defined] -from scverse_misc import Deprecation, deprecated +from scverse_misc import Deprecation, deprecated, deprecated_arg @pytest.fixture(params=[pytest.param(None, id="no_message"), pytest.param("Test message.", id="message")]) @@ -11,42 +14,72 @@ def msg(request: pytest.FixtureRequest) -> str | None: return cast(str | None, request.param) -@pytest.fixture( - params=[ - pytest.param(None, id="no_docstring"), - pytest.param("Test function", id="short"), - pytest.param( - """Test function +type DocstringStyles = Literal["no_docstring", "short", "long_numpystyle", "long_googlestyle"] + + +@pytest.fixture(params=get_args(DocstringStyles.__value__)) +def docstring_style(request: pytest.FixtureRequest) -> DocstringStyles: + return cast(DocstringStyles, request.param) + + +@pytest.fixture +def docstring(docstring_style: DocstringStyles) -> str | None: + match docstring_style: + case "no_docstring": + return None + case "short": + return "Test function" + case "long_numpystyle": + return """Test function This is a test. Parameters ---------- - foo + positional_only_no_default + foo + positional_only_default bar - bar + positional_or_keyword_default baz - """, - id="long", - ), - ] -) -def docstring(request: pytest.FixtureRequest) -> str | None: - return cast(str | None, request.param) + keyword_only_default + foobar + """ + case "long_googlestyle": + return """Test function + + This is a test. + + Args: + positional_only_no_default: foo + positional_only_default: bar + positional_or_keyword_default: baz + keyword_only_default: foobar + """ @pytest.fixture -def deprecated_func(msg: str | None, docstring: str | None) -> Callable[[int, int], int]: - def func(foo: int, bar: int) -> int: +def func(msg: str | None, docstring: str | None) -> Callable[..., int]: + def _func( + positional_only_no_default: int, + positional_only_default: int = 1337, + /, + positional_or_keyword_default: int = 42, + *, + keyword_only_default: float = 3.1415, + ) -> int: return 42 - func.__doc__ = docstring + _func.__doc__ = docstring + return _func + + +@pytest.fixture +def deprecated_func(msg: str | None, func: Callable[..., int]) -> Callable[..., int]: return deprecated(Deprecation("foo", msg or ""))(func) -def test_deprecation_decorator( - deprecated_func: Callable[[int, int], int], docstring: str | None, msg: str | None -) -> None: +def test_deprecation_decorator(deprecated_func: Callable[..., int], docstring: str | None, msg: str | None) -> None: with pytest.warns(FutureWarning, match="deprecated"): assert deprecated_func(1, 2) == 42 @@ -63,3 +96,45 @@ def test_deprecation_decorator( assert len(lines) == 3 or not lines[3].startswith(" ") else: assert lines[3] == f" {msg}" + + +@pytest.mark.parametrize( + "arg", + ("positional_only_no_default", "positional_only_default", "positional_or_keyword_default", "keyword_only_default"), +) +def test_deprecated_arg_decorator( + func: Callable[..., int], msg: str | None, arg: str, docstring_style: DocstringStyles +) -> None: + deprecated_func = deprecated_arg(arg, Deprecation("2.718", msg or ""))(func) + with pytest.warns(FutureWarning, match=f"{arg} is deprecated"): + assert deprecated_func(1, 2, 3, keyword_only_default=4.0) == 42 + + if arg != "positional_only_no_default": + with warnings.catch_warnings(): + warnings.simplefilter("error") + assert deprecated_func(1) == 42 + + parser: type[NumpyDocstring] | type[GoogleDocstring] | None = None + if docstring_style == "long_numpystyle": + parser = NumpyDocstring + elif docstring_style == "long_googlestyle": + parser = GoogleDocstring + + if parser is None: + return + + lines = parser(inspect.getdoc(deprecated_func) or "").lines() + + for i, line in enumerate(lines): + if line.startswith(prefix := f":param {arg}: "): + prefixlen = len(prefix) + if msg is not None: + stripped = lines[i + 1].strip() + assert stripped == ".. version-deprecated:: 2.718" + assert lines[i + 2][prefixlen:] == f" {msg}" + assert not lines[i + 3] + assert lines[i + 4][:prefixlen] == " " * prefixlen + else: + assert line == f":param {arg}: .. version-deprecated:: 2.718" + assert not lines[i + 1] + assert lines[i + 2][:prefixlen] == " " * prefixlen From 6ab96c0dd47b59ddbd46cfa3e7fcb18f42e4a98c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 8 May 2026 11:11:06 +0000 Subject: [PATCH 2/2] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/scverse_misc/_deprecated.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/scverse_misc/_deprecated.py b/src/scverse_misc/_deprecated.py index 184c057..a87bdb6 100644 --- a/src/scverse_misc/_deprecated.py +++ b/src/scverse_misc/_deprecated.py @@ -4,7 +4,6 @@ import sys from functools import wraps from textwrap import indent -from textwrap import indent from typing import TYPE_CHECKING, LiteralString from warnings import warn