Skip to content
Draft
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 .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,12 @@ repos:
# Check that there are no merge conflicts (could be generated by template sync)
- id: check-merge-conflict
args: [--assume-in-merge]
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.20.1
hooks:
- id: mypy
args: []
additional_dependencies:
- pytest
- sphinx
- sphinxcontrib-katex
4 changes: 2 additions & 2 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
author = info["Author"]
copyright = f"{datetime.now():%Y}, {author}."
version = info["Version"]
urls = dict(pu.split(", ") for pu in info.get_all("Project-URL"))
urls = dict(pu.split(", ") for pu in info.get_all("Project-URL") or ())
repository_url = urls["Source"]

# The full version, including alpha/beta/rc tags
Expand Down Expand Up @@ -130,7 +130,7 @@
pygments_style = "default"
katex_prerender = shutil.which(katex.NODEJS_BINARY) is not None

nitpick_ignore = [
nitpick_ignore: list[tuple[str, str]] = [
# If building the documentation fails because of a missing link that is outside your control,
# you can add an exception to this list.
# ("py:class", "igraph.Graph"),
Expand Down
10 changes: 6 additions & 4 deletions docs/extensions/typed_returns.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
from collections.abc import Generator, Iterable

from sphinx.application import Sphinx
from sphinx.ext.napoleon import NumpyDocstring
from sphinx.ext.napoleon.docstring import NumpyDocstring, GoogleDocstring
from sphinx.util.typing import ExtensionMetadata


def _process_return(lines: Iterable[str]) -> Generator[str, None, None]:
Expand All @@ -17,7 +18,7 @@ def _process_return(lines: Iterable[str]) -> Generator[str, None, None]:
yield line


def _parse_returns_section(self: NumpyDocstring, section: str) -> list[str]:
def _parse_returns_section(self: GoogleDocstring, section: str) -> list[str]:
lines_raw = self._dedent(self._consume_to_next_section())
if lines_raw[0] == ":":
del lines_raw[0]
Expand All @@ -27,6 +28,7 @@ def _parse_returns_section(self: NumpyDocstring, section: str) -> list[str]:
return lines


def setup(app: Sphinx):
def setup(app: Sphinx) -> ExtensionMetadata:
"""Set app."""
NumpyDocstring._parse_returns_section = _parse_returns_section
NumpyDocstring._parse_returns_section = _parse_returns_section # type: ignore[method-assign]
return ExtensionMetadata(parallel_read_safe=True)
10 changes: 6 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,9 @@ maintainers = [
authors = [
{ name = "Ilia Kats" },
]
requires-python = ">=3.10"
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Also change the test matrix, please.

requires-python = ">=3.12"
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",
"Programming Language :: Python :: 3.14",
Expand Down Expand Up @@ -55,7 +53,6 @@ doc = [
]

[tool.hatch]
build.hooks.vcs.version-file = "src/scverse_misc/_version.py"
envs.default.installer = "uv"
envs.default.dependency-groups = [ "dev" ]
envs.docs.dependency-groups = [ "doc" ]
Expand Down Expand Up @@ -115,6 +112,11 @@ lint.per-file-ignores."docs/*" = [ "I" ]
lint.per-file-ignores."tests/*" = [ "D" ]
lint.pydocstyle.convention = "google"

[tool.mypy]
strict = true
explicit_package_bases = true
mypy_path = [ "$MYPY_CONFIG_FILE_DIR/stubs", "$MYPY_CONFIG_FILE_DIR/src" ]

[tool.pytest]
strict = true
testpaths = [ "tests" ]
Expand Down
3 changes: 2 additions & 1 deletion src/scverse_misc/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from ._deprecated import Deprecation, deprecated
from ._extensions import ExtensionNamespace, make_register_namespace_decorator
from ._version import __version__, __version_tuple__

__all__ = ["ExtensionNamespace", "make_register_namespace_decorator", "deprecated", "Deprecation"]
20 changes: 10 additions & 10 deletions src/scverse_misc/_deprecated.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,7 @@

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
from typing import TYPE_CHECKING, LiteralString

if sys.version_info >= (3, 13):
from warnings import deprecated as _deprecated
Expand All @@ -17,7 +12,8 @@
if TYPE_CHECKING:
from collections.abc import Callable

F = TypeVar("F", bound=Callable)

__all__ = ["deprecated", "Deprecation"]


class Deprecation(str):
Expand All @@ -28,13 +24,17 @@ class Deprecation(str):
msg: The deprecation message.
"""

def __new__(cls, version_deprecated: LiteralString, msg: LiteralString = "") -> LiteralString:
version_deprecated: LiteralString

def __new__(cls, version_deprecated: LiteralString, msg: LiteralString = "") -> LiteralString: # type: ignore[misc]
obj = super().__new__(cls, msg)
obj.version_deprecated = version_deprecated
return obj


def _deprecated_at(msg: Deprecation, *, category=FutureWarning, stacklevel=1) -> Callable[[F], F]:
def _deprecated_at[F: Callable[..., object]](
msg: Deprecation, *, category: type[Warning] = FutureWarning, stacklevel: int = 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.
Expand All @@ -56,7 +56,7 @@ def decorate(func: F) -> F:

doc = getdoc(func)
docmsg = f".. version-deprecated:: {msg.version_deprecated}"
if len(msg) is not None:
if len(msg):
docmsg += f"\n {msg}"
warnmsg += f" {msg}"

Expand Down
46 changes: 22 additions & 24 deletions src/scverse_misc/_extensions.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,24 @@
"""System to add extension attributes to classes.

Based off of the extension framework in Polars:
https://github.com/pola-rs/polars/blob/main/py-polars/polars/api.py
"""

from __future__ import annotations

import inspect
import sys
import warnings
from itertools import islice
from typing import TYPE_CHECKING, Generic, Literal, Protocol, TypeVar, get_type_hints, overload, runtime_checkable
from typing import TYPE_CHECKING, Literal, Protocol, get_type_hints, overload, runtime_checkable

if TYPE_CHECKING:
from collections.abc import Callable, Set


__all__ = ["make_register_namespace_decorator", "ExtensionNamespace"]


@runtime_checkable
class ExtensionNamespace(Protocol):
"""Protocol for extension namespaces.
Expand All @@ -19,21 +29,11 @@ class ExtensionNamespace(Protocol):
checking with mypy and IDEs.
"""

def __init__(self, instance) -> None:
def __init__(self, instance: object) -> None:
"""Used to enforce the correct signature for extension namespaces."""


# Based off of the extension framework in Polars
# https://github.com/pola-rs/polars/blob/main/py-polars/polars/api.py

__all__ = ["make_register_namespace_decorator", "ExtensionNamespace"]


NameSpT = TypeVar("NameSpT", bound=ExtensionNamespace)
T = TypeVar("T")


class AccessorNameSpace(ExtensionNamespace, Generic[NameSpT]):
class AccessorNameSpace[T, NameSpT: ExtensionNamespace]:
"""Establish property-like namespace object for user-defined functionality."""

def __init__(self, name: str, namespace: type[NameSpT]) -> None:
Expand All @@ -50,7 +50,7 @@ def __get__(self, instance: T | None, cls: type[T]) -> NameSpT | type[NameSpT]:
if instance is None:
return self._ns

ns_instance = self._ns(instance) # type: ignore[call-arg]
ns_instance = self._ns(instance)
setattr(instance, self._accessor, ns_instance)
return ns_instance

Expand Down Expand Up @@ -81,17 +81,15 @@ def _check_namespace_signature(ns_class: type, cls: type, canonical_instance_nam
TypeError: If both the name and type annotation of the second parameter are incorrect.

"""
sig = inspect.signature(ns_class.__init__)
sig = inspect.signature(ns_class.__init__) # type: ignore[misc] # https://github.com/python/mypy/issues/21236
params = sig.parameters

# Ensure there are at least two parameters (self and mdata)
if len(params) < 2:
raise TypeError(f"Namespace initializer must accept a {cls.__name__} instance as the second parameter.")

# Get the second parameter (expected to be `canonical_instance_name`)
param = iter(params.values())
next(param)
param = next(param)
[_, param, *_] = params.values()
if param.annotation is inspect.Parameter.empty:
raise AttributeError(
f"Namespace initializer's second parameter must be annotated as the {cls.__name__!r} class, got empty annotation."
Expand All @@ -101,7 +99,7 @@ def _check_namespace_signature(ns_class: type, cls: type, canonical_instance_nam

# Resolve the annotation using get_type_hints to handle forward references and aliases.
try:
type_hints = get_type_hints(ns_class.__init__)
type_hints = get_type_hints(ns_class.__init__) # type: ignore[misc] # https://github.com/python/mypy/issues/21236
resolved_type = type_hints.get(param.name, param.annotation)
except NameError as e:
raise NameError(
Expand Down Expand Up @@ -130,7 +128,7 @@ def _check_namespace_signature(ns_class: type, cls: type, canonical_instance_nam
)


def _create_namespace(
def _create_namespace[NameSpT: ExtensionNamespace](
name: str, cls: type, reserved_namespaces: Set[str], canonical_instance_name: str
) -> Callable[[type[NameSpT]], type[NameSpT]]:
"""Register custom namespace against the underlying class."""
Expand All @@ -149,14 +147,14 @@ def namespace(ns_class: type[NameSpT]) -> type[NameSpT]:
return namespace


def _indent_string_lines(string: str, indentation_level: int, skip_lines: int = 0):
minspace = 1e6
def _indent_string_lines(string: str, indentation_level: int, skip_lines: int = 0) -> str:
minspace = sys.maxsize
for line in islice(string.splitlines(), 1, None):
for i, char in enumerate(line):
if not char.isspace():
minspace = min(minspace, i)
break
if minspace == 1e6: # single-line string
if minspace == sys.maxsize: # single-line string
minspace = 0
return "\n".join(
" " * 4 * indentation_level + sline if i >= skip_lines else sline
Expand All @@ -165,7 +163,7 @@ def _indent_string_lines(string: str, indentation_level: int, skip_lines: int =
)


def make_register_namespace_decorator(
def make_register_namespace_decorator[NameSpT: ExtensionNamespace](
cls: type, canonical_instance_name: str, decorator_name: str, docstring_style: Literal["google", "numpy"] = "google"
) -> Callable[[str], Callable[[type[NameSpT]], type[NameSpT]]]:
"""Create a decorator for registering custom functionality with a class.
Expand Down
2 changes: 2 additions & 0 deletions stubs/sphinxcontrib/__init__.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# mypy doesn’t understand namespace packages apparently
from . import katex as katex
8 changes: 8 additions & 0 deletions stubs/sphinxcontrib/katex.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
STARTUP_TIMEOUT: float
"""How long to wait for the render server to start in seconds."""

RENDER_TIMEOUT: float
"""Timeout per rendering request in seconds."""

NODEJS_BINARY: str
"""nodejs binary to run javascript."""
26 changes: 17 additions & 9 deletions tests/test_deprecation_decorator.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
from collections.abc import Callable
from typing import cast

import pytest

from scverse_misc import Deprecation, deprecated


@pytest.fixture(params=[None, "Test message."])
def msg(request: pytest.FixtureRequest):
return request.param
def msg(request: pytest.FixtureRequest) -> str | None:
return cast(str | None, request.param)


@pytest.fixture(
Expand All @@ -25,23 +28,26 @@ def msg(request: pytest.FixtureRequest):
""",
]
)
def docstring(request):
return request.param
def docstring(request: pytest.FixtureRequest) -> str | None:
return cast(str | None, request.param)


@pytest.fixture
def deprecated_func(msg, docstring):
def func(foo, bar):
def deprecated_func(msg: str | None, docstring: str | None) -> Callable[[int, int], int]:
def func(foo: int, bar: int) -> int:
return 42

func.__doc__ = docstring
return deprecated(Deprecation("foo", msg))(func)
return deprecated(Deprecation("foo", msg or ""))(func)


def test_deprecation_decorator(deprecated_func, docstring, msg):
def test_deprecation_decorator(
deprecated_func: Callable[[int, int], int], docstring: str | None, msg: str | None
) -> None:
with pytest.warns(FutureWarning, match="deprecated"):
assert deprecated_func(1, 2) == 42

assert deprecated_func.__doc__ is not None
lines = deprecated_func.__doc__.expandtabs().splitlines()
if docstring is None:
assert lines[0].startswith(".. version-deprecated::")
Expand All @@ -50,5 +56,7 @@ def test_deprecation_decorator(deprecated_func, docstring, msg):
assert lines[0] == lines_orig[0]
assert len(lines[1].strip()) == 0
assert lines[2].startswith(".. version-deprecated")
if msg is not None:
if msg is None:
assert len(lines) == 3
else:
assert lines[3] == f" {msg}"
Loading
Loading