diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf2df43..f6e6a29 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,31 +18,67 @@ env: FORCE_COLOR: "1" jobs: - build: + get-environments: + runs-on: ubuntu-latest + outputs: + envs: ${{ steps.get-envs.outputs.envs }} + steps: + - uses: actions/checkout@v5 + with: { filter: "blob:none", fetch-depth: 0 } + - uses: astral-sh/setup-uv@v7 + with: { enable-cache: false } + - id: get-envs + run: | + ENVS_JSON=$(NO_COLOR=1 uvx hatch env show --json | jq -c 'to_entries + | map( + select(.key | startswith("hatch-test")) + | { name: .key, python: .value.python } + )') + echo "envs=${ENVS_JSON}" | tee $GITHUB_OUTPUT + + test: + needs: get-environments runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.10", "3.11", "3.12"] + env: ${{ fromJSON(needs.get-environments.outputs.envs) }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 + with: { fetch-depth: 0, filter: "blob:none" } + - uses: astral-sh/setup-uv@v7 with: - fetch-depth: 0 - filter: blob:none - - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - uses: hynek/setup-cached-uv@v2 - with: - cache-dependency-path: pyproject.toml - - name: dependencies - # TODO: remove typer constraint after fixing https://github.com/WaylonWalker/coverage-rich/issues/4 - run: uv pip install --system .[test,typehints,myst] coverage-rich 'typer <0.14' 'anyconfig[toml] >=0.14' - - name: tests - run: coverage run -m pytest --verbose --color=yes - - name: show coverage - run: coverage-rich report + python-version: ${{ matrix.env.python-version }} + - name: Install dependencies + run: | + uv tool install hatch + hatch -v env create ${{ matrix.env.name }} + - name: Run tests + run: | + hatch run ${{ matrix.env.name }}:run-cov -v --color=yes -n auto --junitxml=test-data/test-results.xml + hatch run ${{ matrix.env.name }}:cov-combine + hatch run ${{ matrix.env.name }}:coverage xml - name: upload coverage + if: ${{ !cancelled() }} uses: codecov/codecov-action@v5 with: + token: c66a2830-d3c7-4ae7-a230-21aef89dcf65 + flags: ${{ matrix.env.name }} fail_ci_if_error: true + - name: Upload test results + if: ${{ !cancelled() }} + uses: codecov/test-results-action@v1 + with: token: c66a2830-d3c7-4ae7-a230-21aef89dcf65 + flags: ${{ matrix.env.name }} + fail_ci_if_error: true + + check: + if: always() + needs: + - get-environments + - test + runs-on: ubuntu-latest + steps: + - uses: re-actors/alls-green@release/v1 + with: + jobs: ${{ toJSON(needs) }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fe479ab..5c7abfc 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -26,3 +26,4 @@ repos: - types-docutils - legacy-api-wrap - myst-parser + - sphinx-autodoc-typehints diff --git a/docs/conf.py b/docs/conf.py index cbe48a8..fd9e1ac 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -5,8 +5,7 @@ import re from typing import TYPE_CHECKING from pathlib import PurePosixPath -from datetime import datetime -from datetime import timezone as tz +from datetime import UTC, datetime from importlib.metadata import metadata from jinja2.tests import TESTS @@ -42,7 +41,7 @@ meta = metadata("scanpydoc") project = meta["name"] author = meta["author-email"].split(" <")[0] -copyright = f"{datetime.now(tz=tz.utc):%Y}, {author}." # noqa: A001 +copyright = f"{datetime.now(tz=UTC):%Y}, {author}." # noqa: A001 version = release = meta["version"] master_doc = "index" diff --git a/pyproject.toml b/pyproject.toml index 4149b0c..4bc439e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ classifiers = [ 'Framework :: Sphinx :: Extension', 'Typing :: Typed', ] -requires-python = '>=3.10' +requires-python = '>=3.12' dependencies = [ 'sphinx>=7.0', ] @@ -94,7 +94,7 @@ version-file = 'src/scanpydoc/_version.py' [tool.hatch.envs.default] dependencies = ['types-docutils'] [tool.hatch.envs.docs] -python = '3.11' +python = '3.13' features = ['doc'] [tool.hatch.envs.docs.scripts] build = 'sphinx-build -M html docs docs/_build' @@ -102,6 +102,8 @@ clean = 'git clean -fdX docs' [tool.hatch.envs.hatch-test] features = ['test', 'typehints', 'myst'] +[[tool.hatch.envs.hatch-test.matrix]] +python = ['3.12', '3.14'] [tool.pytest.ini_options] addopts = [ @@ -115,8 +117,10 @@ filterwarnings = [ [tool.coverage.run] source_pkgs = ['scanpydoc'] +concurrency = ['multiprocessing'] +patch = ['subprocess'] [tool.coverage.paths] -scanpydoc = ['src/scanpydoc'] +source = ['src'] [tool.coverage.report] exclude_lines = [ 'no cov', diff --git a/src/scanpydoc/__init__.py b/src/scanpydoc/__init__.py index f1cb5c1..4d92556 100644 --- a/src/scanpydoc/__init__.py +++ b/src/scanpydoc/__init__.py @@ -5,7 +5,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, TypeVar +from typing import TYPE_CHECKING, Any from textwrap import indent from collections.abc import Callable @@ -31,10 +31,8 @@ :ref:`Metadata ` for this extension. """ -C = TypeVar("C", bound=Callable[..., Any]) - -def _setup_sig(fn: C) -> C: +def _setup_sig[C: Callable[..., Any]](fn: C) -> C: fn.__doc__ = f"{fn.__doc__ or ''}\n\n{indent(setup_sig_str, ' ' * 4)}" return fn diff --git a/src/scanpydoc/autosummary_generate_imported.py b/src/scanpydoc/autosummary_generate_imported.py index 8dbf389..0c23b9f 100644 --- a/src/scanpydoc/autosummary_generate_imported.py +++ b/src/scanpydoc/autosummary_generate_imported.py @@ -13,12 +13,7 @@ if TYPE_CHECKING: - import sys - - if sys.version_info >= (3, 11): - from typing import Never - else: # pragma: no cover - from typing import NoReturn as Never + from typing import Never from sphinx.application import Sphinx diff --git a/src/scanpydoc/definition_list_typed_field.py b/src/scanpydoc/definition_list_typed_field.py index f06d968..b97c3a5 100644 --- a/src/scanpydoc/definition_list_typed_field.py +++ b/src/scanpydoc/definition_list_typed_field.py @@ -20,13 +20,13 @@ if TYPE_CHECKING: - from typing import Any, TypeAlias + from typing import Any from collections.abc import Iterable from sphinx.application import Sphinx from sphinx.environment import BuildEnvironment - TextLikeNode: TypeAlias = nodes.Text | nodes.TextElement + type TextLikeNode = nodes.Text | nodes.TextElement class DLTypedField(PyTypedField): @@ -98,12 +98,10 @@ def handle_item( @_setup_sig def setup(app: Sphinx) -> dict[str, Any]: """Replace :class:`~sphinx.domains.python.PyTypedField` with ours.""" - napoleon_requested = "sphinx.ext.napoleon" in app.config.extensions - napoleon_loaded = next( - (True for ft in PyObject.doc_field_types if ft.name == "keyword"), - False, - ) - if napoleon_requested and not napoleon_loaded: + if ( + "sphinx.ext.napoleon" in app.config.extensions + and "sphinx.ext.napoleon" not in app.extensions + ): msg = f"Please load sphinx.ext.napoleon before {__name__}" raise RuntimeError(msg) diff --git a/src/scanpydoc/elegant_typehints/_formatting.py b/src/scanpydoc/elegant_typehints/_formatting.py index a49a7d4..9496414 100644 --- a/src/scanpydoc/elegant_typehints/_formatting.py +++ b/src/scanpydoc/elegant_typehints/_formatting.py @@ -2,7 +2,7 @@ import inspect from types import GenericAlias -from typing import TYPE_CHECKING, cast, get_args, get_origin +from typing import TYPE_CHECKING, TypeAliasType, cast, get_args, get_origin from sphinx_autodoc_typehints import format_annotation @@ -16,7 +16,7 @@ from sphinx.config import Config -def typehints_formatter(annotation: type[Any], config: Config) -> str | None: +def typehints_formatter(annotation: object, config: Config) -> str | None: """Generate reStructuredText containing links to the types. Can be used as ``typehints_formatter`` for :mod:`sphinx_autodoc_typehints`, @@ -33,6 +33,9 @@ def typehints_formatter(annotation: type[Any], config: Config) -> str | None: ------- reStructuredText describing the type """ + if isinstance(annotation, TypeAliasType): + return format_annotation(annotation.__value__, config) + if inspect.isclass(annotation) and annotation.__module__ == "builtins": return None diff --git a/src/scanpydoc/elegant_typehints/_role_mapping.py b/src/scanpydoc/elegant_typehints/_role_mapping.py index 8cb55ef..89a4c48 100644 --- a/src/scanpydoc/elegant_typehints/_role_mapping.py +++ b/src/scanpydoc/elegant_typehints/_role_mapping.py @@ -50,7 +50,7 @@ def __getitem__(self, key: tuple[str | None, str]) -> tuple[str | None, str]: for known_role in chain([None], {r for r, _ in self}): try: return self.data[known_role, key[1]] - except KeyError: # noqa: PERF203 + except KeyError: pass raise KeyError(key) diff --git a/src/scanpydoc/rtd_github_links/__init__.py b/src/scanpydoc/rtd_github_links/__init__.py index 7f37675..85b01e4 100644 --- a/src/scanpydoc/rtd_github_links/__init__.py +++ b/src/scanpydoc/rtd_github_links/__init__.py @@ -75,10 +75,10 @@ if TYPE_CHECKING: from types import CodeType, FrameType, MethodType, FunctionType, TracebackType - from typing import Any, TypeAlias + from typing import Any from collections.abc import Callable - _SourceObjectType: TypeAlias = ( + type _SourceObjectType = ( ModuleType | type[Any] | MethodType @@ -211,8 +211,7 @@ def github_url(qualname: str) -> str: try: obj, module = _get_obj_module(qualname) except Exception as e: - if sys.version_info >= (3, 11): - e.add_note(f"Qualname: {qualname!r}") + e.add_note(f"Qualname: {qualname!r}") raise assert rtd_links_prefix is not None # noqa: S101 path = rtd_links_prefix / _module_path(obj, module) diff --git a/src/scanpydoc/rtd_github_links/_linkcode.py b/src/scanpydoc/rtd_github_links/_linkcode.py index 168367e..ea038e6 100644 --- a/src/scanpydoc/rtd_github_links/_linkcode.py +++ b/src/scanpydoc/rtd_github_links/_linkcode.py @@ -20,10 +20,10 @@ class JSInfo(TypedDict): if TYPE_CHECKING: - from typing import Literal, TypeAlias + from typing import Literal Domain = Literal["py", "c", "cpp", "javascript"] - DomainInfo: TypeAlias = PyInfo | CInfo | JSInfo + type DomainInfo = PyInfo | CInfo | JSInfo @overload diff --git a/src/scanpydoc/rtd_github_links/_testdata.py b/src/scanpydoc/rtd_github_links/_testdata.py index 4fe3430..2edddd3 100644 --- a/src/scanpydoc/rtd_github_links/_testdata.py +++ b/src/scanpydoc/rtd_github_links/_testdata.py @@ -12,19 +12,25 @@ from typing import TypeAlias +class _G[T]: + pass + + _T = TypeVar("_T") -class _G(Generic[_T]): +class _G_Old(Generic[_T]): # noqa: N801, UP046 pass # make sure that TestGenericClass keeps its __module__ -_G.__module__ = "somewhere_else" +_G.__module__ = _G_Old.__module__ = "somewhere_else" -TestGenericBuiltin: TypeAlias = list[str] -TestGenericClass: TypeAlias = _G[int] +type TestGenericBuiltin = list[str] +type TestGenericClass = _G[int] +TestGenericBuiltinOld: TypeAlias = list[str] # noqa: UP040 +TestGenericClassOld: TypeAlias = _G_Old[int] # noqa: UP040 @dataclass diff --git a/tests/test_elegant_typehints.py b/tests/test_elegant_typehints.py index b9bce4d..4fbce34 100644 --- a/tests/test_elegant_typehints.py +++ b/tests/test_elegant_typehints.py @@ -67,8 +67,10 @@ class SubCl(Class): pass class Excep(RuntimeError): pass class Excep2(Excep): pass - T = TypeVar('T') - class Gen(Generic[T]): pass + class Gen[T]: pass + + _T = TypeVar('T') + class GenOld(Generic[_T]): pass """, ) @@ -89,6 +91,7 @@ def app(make_app_setup: MakeApp) -> Sphinx: "testmod.Excep": "test.Excep", "testmod.Excep2": ("py:exc", "test.Excep2"), "testmod.Gen": "test.Gen", + "testmod.GenOld": "test.GenOld", }, ) @@ -131,8 +134,14 @@ def test_app(app: Sphinx) -> None: assert "testmod.Class" in app.config.qualname_overrides -def test_default(app: Sphinx) -> None: - assert typehints_formatter(str, app.config) is None +@pytest.mark.parametrize("annotation", [str, int | str], ids=["type", "union"]) +def test_default(app: Sphinx, annotation: object) -> None: + assert typehints_formatter(annotation, app.config) is None + + +def test_typealiastype(app: Sphinx) -> None: + type Foo = int # pyright: ignore[reportGeneralTypeIssues] + assert typehints_formatter(Foo, app.config) == ":py:class:`int`" def _escape_sat(rst: str) -> str: @@ -240,6 +249,11 @@ def fn_test(m: Mapping[str, int] = {}) -> None: # pragma: no cover r":py:class:`~test.Gen`\ \[:py:class:`~test.Class`]", id="generic", ), + pytest.param( + lambda m: m.GenOld[m.Class], + r":py:class:`~test.GenOld`\ \[:py:class:`~test.Class`]", + id="generic-old", + ), ], ) def test_qualname_overrides( @@ -329,18 +343,11 @@ def test_typing_classes(app: Sphinx, annotation: type) -> None: getattr(annotation, "_name", None) or getattr(annotation, "__name__", None) or getattr(get_origin(annotation), "_name", None) - # 3.6 _Any and _Union - or annotation.__class__.__name__[1:] ) output = typehints_formatter(annotation, app.config) assert output is None or output.startswith(f":py:data:`typing.{name}") -def test_union_type(app: Sphinx) -> None: - union = eval("int | str") # noqa: S307 - assert typehints_formatter(union, app.config) is None - - @pytest.mark.parametrize( ("direc", "base", "sub"), [ diff --git a/tests/test_release_notes.py b/tests/test_release_notes.py index cb8826f..5178719 100644 --- a/tests/test_release_notes.py +++ b/tests/test_release_notes.py @@ -15,7 +15,7 @@ if TYPE_CHECKING: - from typing import Literal, TypeAlias + from typing import Literal from pathlib import Path from collections.abc import Mapping @@ -24,7 +24,7 @@ from scanpydoc.testing import MakeApp - Tree: TypeAlias = Mapping[str | Path, "Tree | str"] + type Tree = Mapping[str | Path, "Tree | str"] def mkfiles(root: Path, tree: Tree = MappingProxyType({})) -> None: diff --git a/tests/test_rtd_github_links.py b/tests/test_rtd_github_links.py index 5289501..895e5c1 100644 --- a/tests/test_rtd_github_links.py +++ b/tests/test_rtd_github_links.py @@ -150,7 +150,19 @@ def test_as_function( assert github_url(f"scanpydoc.{module}.{name}") == f"{prefix}/{obj_path}#L{s}-L{e}" -@pytest.mark.parametrize("cls", [dict, Mapping]) +@pytest.mark.parametrize( + "cls", + [ + pytest.param(dict, id="dict"), + pytest.param( + Mapping, + marks=pytest.mark.skipif( + sys.version_info >= (3, 13), + reason="In Python 3.13+, Mapping is a regular class", + ), + ), + ], +) def test_no_line_nos_for_unavailable_source(cls: type) -> None: start, end = _get_linenos(cls) assert start is end is None @@ -166,8 +178,7 @@ def test_get_github_url_only_annotation(prefix: PurePosixPath) -> None: def test_get_github_url_error() -> None: with pytest.raises(KeyError) as exc_info: github_url("test.nonexistant.Thingamajig") - if sys.version_info >= (3, 11): - assert exc_info.value.__notes__[0] == "Qualname: 'test.nonexistant.Thingamajig'" + assert exc_info.value.__notes__[0] == "Qualname: 'test.nonexistant.Thingamajig'" @pytest.mark.parametrize( @@ -225,6 +236,20 @@ def test_get_github_url_error() -> None: "scanpydoc/rtd_github_links/_testdata.py", id="generic_class", ), + pytest.param( + "scanpydoc.rtd_github_links._testdata.TestGenericBuiltinOld", + _testdata.TestGenericBuiltinOld, + _testdata, + "scanpydoc/rtd_github_links/_testdata.py", + id="generic_builtin_old", + ), + pytest.param( + "scanpydoc.rtd_github_links._testdata.TestGenericClassOld", + _testdata.TestGenericClassOld, + _testdata, + "scanpydoc/rtd_github_links/_testdata.py", + id="generic_class_old", + ), ], ) def test_get_obj_module_path(