From ed4844932997f762f4d067214b378cac76ec6fdb Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Wed, 15 Apr 2026 16:34:31 +0200 Subject: [PATCH] chore: type checking --- .pre-commit-config.yaml | 9 ++++++ docs/conf.py | 4 +-- docs/extensions/typed_returns.py | 10 ++++--- pyproject.toml | 10 ++++--- src/scverse_misc/__init__.py | 3 +- src/scverse_misc/_deprecated.py | 20 ++++++------- src/scverse_misc/_extensions.py | 46 ++++++++++++++--------------- stubs/sphinxcontrib/__init__.pyi | 2 ++ stubs/sphinxcontrib/katex.pyi | 8 +++++ tests/test_deprecation_decorator.py | 26 ++++++++++------ tests/test_extensions.py | 26 +++++++++------- 11 files changed, 100 insertions(+), 64 deletions(-) create mode 100644 stubs/sphinxcontrib/__init__.pyi create mode 100644 stubs/sphinxcontrib/katex.pyi diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c0d3635..cfdfc12 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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 diff --git a/docs/conf.py b/docs/conf.py index 33efa96..51567ed 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -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 @@ -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"), diff --git a/docs/extensions/typed_returns.py b/docs/extensions/typed_returns.py index 0fbffef..f7481a3 100644 --- a/docs/extensions/typed_returns.py +++ b/docs/extensions/typed_returns.py @@ -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]: @@ -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] @@ -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) diff --git a/pyproject.toml b/pyproject.toml index 853b925..83d6dcc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,11 +13,9 @@ maintainers = [ authors = [ { name = "Ilia Kats" }, ] -requires-python = ">=3.10" +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", @@ -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" ] @@ -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" ] diff --git a/src/scverse_misc/__init__.py b/src/scverse_misc/__init__.py index 5f5e3cc..9797160 100644 --- a/src/scverse_misc/__init__.py +++ b/src/scverse_misc/__init__.py @@ -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"] diff --git a/src/scverse_misc/_deprecated.py b/src/scverse_misc/_deprecated.py index aba90e2..f439e00 100644 --- a/src/scverse_misc/_deprecated.py +++ b/src/scverse_misc/_deprecated.py @@ -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 @@ -17,7 +12,8 @@ if TYPE_CHECKING: from collections.abc import Callable - F = TypeVar("F", bound=Callable) + +__all__ = ["deprecated", "Deprecation"] class Deprecation(str): @@ -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. @@ -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}" diff --git a/src/scverse_misc/_extensions.py b/src/scverse_misc/_extensions.py index 90a4fa1..433baf7 100644 --- a/src/scverse_misc/_extensions.py +++ b/src/scverse_misc/_extensions.py @@ -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. @@ -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: @@ -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 @@ -81,7 +81,7 @@ 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) @@ -89,9 +89,7 @@ def _check_namespace_signature(ns_class: type, cls: type, canonical_instance_nam 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." @@ -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( @@ -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.""" @@ -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 @@ -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. diff --git a/stubs/sphinxcontrib/__init__.pyi b/stubs/sphinxcontrib/__init__.pyi new file mode 100644 index 0000000..8929d39 --- /dev/null +++ b/stubs/sphinxcontrib/__init__.pyi @@ -0,0 +1,2 @@ +# mypy doesn’t understand namespace packages apparently +from . import katex as katex diff --git a/stubs/sphinxcontrib/katex.pyi b/stubs/sphinxcontrib/katex.pyi new file mode 100644 index 0000000..645fd71 --- /dev/null +++ b/stubs/sphinxcontrib/katex.pyi @@ -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.""" diff --git a/tests/test_deprecation_decorator.py b/tests/test_deprecation_decorator.py index 1d7e931..b9bb6d2 100644 --- a/tests/test_deprecation_decorator.py +++ b/tests/test_deprecation_decorator.py @@ -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( @@ -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::") @@ -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}" diff --git a/tests/test_extensions.py b/tests/test_extensions.py index ede33fe..9ceb285 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Protocol import pytest @@ -11,16 +11,20 @@ from collections.abc import Generator +class Greeter(Protocol): + def __init__(self, obj: DummyClass) -> None: ... + def greet(self) -> str: ... + + class DummyClass: - foo = [] + foo: list[object] = [] bar = None @property - def baz(self): - pass + def baz(self) -> None: ... + def foobar(self) -> None: ... - def foobar(self): - pass + dummy: Greeter # available when using `dummy_namespace` fixture register_dummy_namespace = make_register_namespace_decorator(DummyClass, "obj", "register_dummy_namespace") @@ -72,16 +76,18 @@ def test_accessor_namespace() -> None: # Define a dummy namespace class to be used via the descriptor. class DummyNamespace: - def __init__(self, obj: DummyClass): + def __init__(self, obj: Dummy): self._obj = obj def foo(self) -> str: return "foo" class Dummy: - pass + dummy: DummyNamespace # just typing, runtime added below - descriptor = extensions.AccessorNameSpace("dummy", DummyNamespace) + descriptor: extensions.AccessorNameSpace[Dummy, DummyNamespace] = extensions.AccessorNameSpace( + "dummy", DummyNamespace + ) # When accessed on the class, it should return the namespace type. ns_class = descriptor.__get__(None, Dummy) @@ -204,7 +210,7 @@ def test_missing_annotation() -> None: @register_dummy_namespace("missing_annotation") class MissingAnnotationNamespace: - def __init__(self, obj) -> None: + def __init__(self, obj) -> None: # type: ignore[no-untyped-def] self.obj = obj