Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,12 @@ Types used by the former:

ExtensionNamespace
```

## Deprecations
``` {eval-rst}
.. autosummary::
:toctree: generated

deprecated
Deprecation
```
6 changes: 4 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@ maintainers = [
authors = [
{ name = "Ilia Kats" },
]
requires-python = ">=3.11"
requires-python = ">=3.10"
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need for that. We're following spec-0 so we're more or less already at Python 3.12+

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but mudata 0.3.x is still on 3.10+. I'll bump it for 0.4, but for now I'd prefer to keep it.

Copy link
Copy Markdown
Member

@Zethson Zethson Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think MuData received so many changes, it's fine to just release 0.4.0 next. The current MuData main branch can already be 3.12+.

But this is not a big problem (unless you have to adapt it again - see Phil's Generics suggestion) so whatever you think makes sense.

Thanks!

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Everything so far has been backwards compatible, so my plan currently is to release 0.3.4 after the deprecation PR has been merged, this will be the last 0.3.x release.

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",
Expand All @@ -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/"
Expand Down Expand Up @@ -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" ] },
]
Expand Down
1 change: 1 addition & 0 deletions src/scverse_misc/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
from ._deprecated import Deprecation, deprecated
from ._extensions import ExtensionNamespace, make_register_namespace_decorator
from ._version import __version__, __version_tuple__
79 changes: 79 additions & 0 deletions src/scverse_misc/_deprecated.py
Original file line number Diff line number Diff line change
@@ -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
54 changes: 54 additions & 0 deletions tests/test_deprecation_decorator.py
Original file line number Diff line number Diff line change
@@ -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}"
Loading