From 16857cdfdf540f7b8e931cf802739150e1e3f9a2 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 22 Oct 2024 11:04:22 +0200 Subject: [PATCH] Fix tests for Sphinx 8.1, remove Python 3.9 support (#178) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Philipp A. --- .github/workflows/ci.yml | 10 ++-- .pre-commit-config.yaml | 4 +- .readthedocs.yml | 4 +- pyproject.toml | 4 +- .../elegant_typehints/_formatting.py | 9 +--- .../elegant_typehints/_return_tuple.py | 11 ++-- src/scanpydoc/rtd_github_links/__init__.py | 7 +-- src/scanpydoc/testing.py | 24 +++++++++ tests/conftest.py | 25 ++++++--- tests/test_base.py | 6 +-- tests/test_definition_list_typed_field.py | 13 ++--- tests/test_elegant_typehints.py | 29 ++++------- tests/test_release_notes.py | 51 ++++++++++++------- tests/test_rtd_github_links.py | 4 +- 14 files changed, 116 insertions(+), 85 deletions(-) create mode 100644 src/scanpydoc/testing.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 95f1d1a..958cc75 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.9", "3.10", "3.11", "3.12"] + python-version: ["3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v4 with: @@ -31,11 +31,11 @@ jobs: - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - cache: pip + - uses: hynek/setup-cached-uv@v2 + with: + cache-dependency-path: pyproject.toml - name: dependencies - run: | - pip install --upgrade pip wheel - pip install .[test,typehints,myst] coverage-rich 'anyconfig[toml] >=0.14' + run: uv pip install --system .[test,typehints,myst] coverage-rich 'anyconfig[toml] >=0.14' - name: tests run: coverage run -m pytest --verbose --color=yes - name: show coverage diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c5d374d..ab48b5b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,7 +4,7 @@ repos: hooks: - id: trailing-whitespace - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.9 + rev: v0.7.0 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] @@ -17,7 +17,7 @@ repos: - prettier@3.0.2 - prettier-plugin-jinja-template@1.0.0 - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.11.2 + rev: v1.12.1 hooks: - id: mypy additional_dependencies: diff --git a/.readthedocs.yml b/.readthedocs.yml index 5c88188..ca1d026 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -1,8 +1,8 @@ version: 2 build: - os: ubuntu-22.04 + os: ubuntu-24.04 tools: - python: "3.11" + python: "3.12" python: install: - method: pip diff --git a/pyproject.toml b/pyproject.toml index c3d5c78..ff0b7eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,10 +17,9 @@ classifiers = [ 'Framework :: Sphinx :: Extension', 'Typing :: Typed', ] -requires-python = '>=3.9' +requires-python = '>=3.10' dependencies = [ 'sphinx>=7.0', - 'get-annotations; python_version < "3.10"', ] [project.optional-dependencies] @@ -30,6 +29,7 @@ test = [ 'coverage', 'legacy-api-wrap', 'defusedxml', # sphinx[test] would also pull in cython + 'sphinx>=8.1.0' # https://github.com/sphinx-doc/sphinx/pull/12743 ] doc = [ 'scanpydoc[typehints,myst,theme]', diff --git a/src/scanpydoc/elegant_typehints/_formatting.py b/src/scanpydoc/elegant_typehints/_formatting.py index 24edb3e..821152d 100644 --- a/src/scanpydoc/elegant_typehints/_formatting.py +++ b/src/scanpydoc/elegant_typehints/_formatting.py @@ -1,6 +1,5 @@ from __future__ import annotations -import sys import inspect from types import GenericAlias from typing import TYPE_CHECKING, Any, cast, get_args, get_origin @@ -15,12 +14,6 @@ from sphinx.config import Config -if sys.version_info >= (3, 10): - from types import UnionType -else: # pragma: no cover - UnionType = None - - def typehints_formatter(annotation: type[Any], config: Config) -> str | None: """Generate reStructuredText containing links to the types. @@ -43,7 +36,7 @@ def typehints_formatter(annotation: type[Any], config: Config) -> str | None: tilde = "" if config.typehints_fully_qualified else "~" - if isinstance(annotation, (GenericAlias, _GenericAlias)): + if isinstance(annotation, GenericAlias | _GenericAlias): args = get_args(annotation) annotation = cast(type[Any], get_origin(annotation)) else: diff --git a/src/scanpydoc/elegant_typehints/_return_tuple.py b/src/scanpydoc/elegant_typehints/_return_tuple.py index 8f87da4..0ab72c5 100644 --- a/src/scanpydoc/elegant_typehints/_return_tuple.py +++ b/src/scanpydoc/elegant_typehints/_return_tuple.py @@ -1,8 +1,8 @@ from __future__ import annotations import re -import sys import inspect +from types import UnionType from typing import TYPE_CHECKING, Union, get_args, get_origin, get_type_hints from typing import Tuple as t_Tuple # noqa: UP035 from logging import getLogger @@ -19,12 +19,7 @@ from sphinx.ext.autodoc import Options -if sys.version_info > (3, 10): - from types import UnionType - - UNION_TYPES = {Union, UnionType} -else: # pragma: no cover - UNION_TYPES = {Union} +UNION_TYPES = {Union, UnionType} __all__ = ["process_docstring", "_parse_returns_section", "setup"] @@ -77,7 +72,7 @@ def process_docstring( # noqa: PLR0913 idxs_ret_names = _get_idxs_ret_names(lines) if len(idxs_ret_names) == len(ret_types): - for l, rt in zip(idxs_ret_names, ret_types): + for l, rt in zip(idxs_ret_names, ret_types, strict=False): typ = format_annotation(rt, app.config) if (line := lines[l]).lstrip() in {":returns: :", ":return: :", ":"}: transformed = f"{line[:-1]}{typ}" diff --git a/src/scanpydoc/rtd_github_links/__init__.py b/src/scanpydoc/rtd_github_links/__init__.py index 1747423..fa36184 100644 --- a/src/scanpydoc/rtd_github_links/__init__.py +++ b/src/scanpydoc/rtd_github_links/__init__.py @@ -118,10 +118,7 @@ def _infer_vars(config: Config) -> tuple[str, PurePosixPath]: def _get_annotations(obj: _SourceObjectType) -> dict[str, Any]: - if sys.version_info > (3, 10): - from inspect import get_annotations - else: # pragma: no cover - from get_annotations import get_annotations + from inspect import get_annotations try: return get_annotations(obj) # type: ignore[no-any-return,arg-type,unused-ignore] @@ -159,7 +156,7 @@ def _get_obj_module(qualname: str) -> tuple[Any, ModuleType]: raise e from None if isinstance(thing, ModuleType): # pragma: no cover mod = thing - elif is_dataclass(obj) or isinstance(thing, (GenericAlias, _GenericAlias)): + elif is_dataclass(obj) or isinstance(thing, GenericAlias | _GenericAlias): obj = thing else: obj = thing diff --git a/src/scanpydoc/testing.py b/src/scanpydoc/testing.py new file mode 100644 index 0000000..97adb87 --- /dev/null +++ b/src/scanpydoc/testing.py @@ -0,0 +1,24 @@ +"""Testing utilities.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Protocol + + +if TYPE_CHECKING: + from typing import Any + + from sphinx.testing.util import SphinxTestApp + + +class MakeApp(Protocol): + """Create a SphinxTestApp instance.""" + + def __call__( # noqa: D102 + self, + builder: str = "html", + /, + *, + exception_on_warning: bool = False, + **conf: Any, # noqa: ANN401 + ) -> SphinxTestApp: ... diff --git a/tests/conftest.py b/tests/conftest.py index 94beeb4..38b5249 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,16 +18,29 @@ from pathlib import Path from collections.abc import Callable, Generator - from sphinx.application import Sphinx + from sphinx.testing.util import SphinxTestApp + + from scanpydoc.testing import MakeApp @pytest.fixture -def make_app_setup( - make_app: Callable[..., Sphinx], tmp_path: Path -) -> Callable[..., Sphinx]: - def make_app_setup(builder: str = "html", /, **conf: Any) -> Sphinx: # noqa: ANN401 +def make_app_setup(make_app: type[SphinxTestApp], tmp_path: Path) -> MakeApp: + def make_app_setup( + builder: str = "html", + /, + *, + exception_on_warning: bool = False, + **conf: Any, # noqa: ANN401 + ) -> SphinxTestApp: (tmp_path / "conf.py").write_text("") - return make_app(buildername=builder, srcdir=tmp_path, confoverrides=conf) + conf.setdefault("suppress_warnings", []).append("app.add_node") + return make_app( + buildername=builder, + srcdir=tmp_path, + confoverrides=conf, + warningiserror=exception_on_warning, + exception_on_warning=exception_on_warning, + ) return make_app_setup diff --git a/tests/test_base.py b/tests/test_base.py index 223852d..92675f8 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -11,17 +11,17 @@ if TYPE_CHECKING: - from collections.abc import Callable - import pytest from sphinx.application import Sphinx + from scanpydoc.testing import MakeApp + DEPRECATED = frozenset({"scanpydoc.autosummary_generate_imported"}) def test_all_get_installed( - monkeypatch: pytest.MonkeyPatch, make_app_setup: Callable[..., Sphinx] + monkeypatch: pytest.MonkeyPatch, make_app_setup: MakeApp ) -> None: setups_seen: set[str] = set() setups_called: dict[str, Sphinx] = {} diff --git a/tests/test_definition_list_typed_field.py b/tests/test_definition_list_typed_field.py index 53bed7c..b556eae 100644 --- a/tests/test_definition_list_typed_field.py +++ b/tests/test_definition_list_typed_field.py @@ -11,13 +11,13 @@ if TYPE_CHECKING: - from collections.abc import Callable - from sphinx.application import Sphinx + from scanpydoc.testing import MakeApp + @pytest.fixture -def app(make_app_setup: Callable[..., Sphinx]) -> Sphinx: +def app(make_app_setup: MakeApp) -> Sphinx: app = make_app_setup() app.setup_extension("scanpydoc.definition_list_typed_field") return app @@ -41,7 +41,7 @@ def app(make_app_setup: Callable[..., Sphinx]) -> Sphinx: """ -def test_apps_separate(app: Sphinx, make_app_setup: Callable[..., Sphinx]) -> None: +def test_apps_separate(app: Sphinx, make_app_setup: MakeApp) -> None: app_no_setup = make_app_setup() assert app is not app_no_setup assert "scanpydoc.definition_list_typed_field" in app.extensions @@ -97,11 +97,12 @@ def test_convert_params( assert isinstance(cyr := term[2], nodes.classifier) assert len(cyr) == len(conv_types), cyr.children assert all( - isinstance(cyr_part, conv_type) for cyr_part, conv_type in zip(cyr, conv_types) + isinstance(cyr_part, conv_type) + for cyr_part, conv_type in zip(cyr, conv_types, strict=True) ) -def test_load_error(make_app_setup: Callable[..., Sphinx]) -> None: +def test_load_error(make_app_setup: MakeApp) -> None: with pytest.raises(RuntimeError, match=r"Please load sphinx\.ext\.napoleon before"): make_app_setup( extensions=["scanpydoc.definition_list_typed_field", "sphinx.ext.napoleon"] diff --git a/tests/test_elegant_typehints.py b/tests/test_elegant_typehints.py index 6aa10d0..cdc7cd2 100644 --- a/tests/test_elegant_typehints.py +++ b/tests/test_elegant_typehints.py @@ -3,19 +3,9 @@ from __future__ import annotations import re -import sys import inspect from io import StringIO -from typing import ( - TYPE_CHECKING, - Any, - Union, - AnyStr, - NoReturn, - Optional, - cast, - get_origin, -) +from typing import TYPE_CHECKING, Any, AnyStr, NoReturn, cast, get_origin from pathlib import Path from operator import attrgetter from collections.abc import Mapping, Callable @@ -31,6 +21,8 @@ from sphinx.application import Sphinx + from scanpydoc.testing import MakeApp + class ProcessDoc(Protocol): # noqa: D101 def __call__( # noqa: D102 self, fn: Callable[..., Any], *, run_napoleon: bool = False @@ -60,7 +52,7 @@ class Gen(Generic[T]): pass @pytest.fixture -def app(make_app_setup: Callable[..., Sphinx]) -> Sphinx: +def app(make_app_setup: MakeApp) -> Sphinx: return make_app_setup( master_doc="index", extensions=[ @@ -256,8 +248,8 @@ def fn_test(m: object) -> None: # pragma: no cover AnyStr, NoReturn, Callable[[int], None], - Union[int, str], - Union[int, str, None], + int | str, + int | str | None, ], ids=lambda p: str(p).replace("typing.", ""), ) @@ -274,7 +266,6 @@ def test_typing_classes(app: Sphinx, annotation: type) -> None: assert output is None or output.startswith(f":py:data:`typing.{name}") -@pytest.mark.skipif(sys.version_info < (3, 10), reason="requires Python 3.10+") def test_union_type(app: Sphinx) -> None: union = eval("int | str") # noqa: S307 assert typehints_formatter(union, app.config) is None @@ -389,7 +380,7 @@ class B: ("return_ann", "foo_rendered"), [ pytest.param(tuple[str, int], ":py:class:`str`", id="tuple"), - pytest.param(Optional[tuple[str, int]], ":py:class:`str`", id="tuple | None"), + pytest.param(tuple[str, int] | None, ":py:class:`str`", id="tuple | None"), pytest.param( tuple[Mapping[str, float], int], r":py:class:`~collections.abc.Mapping`\ \[" @@ -397,7 +388,7 @@ class B: "]", id="complex", ), - pytest.param(Optional[int], None, id="int | None"), + pytest.param(int | None, None, id="int | None"), ], ) def test_return_tuple( @@ -472,14 +463,14 @@ def fn() -> tuple[int, str]: # pragma: no cover assert res[2].startswith(":rtype: :sphinx_autodoc_typehints_type:") -def test_load_without_sat(make_app_setup: Callable[..., Sphinx]) -> None: +def test_load_without_sat(make_app_setup: MakeApp) -> None: make_app_setup( master_doc="index", extensions=["sphinx.ext.autodoc", "scanpydoc.elegant_typehints"], ) -def test_load_error(make_app_setup: Callable[..., Sphinx]) -> None: +def test_load_error(make_app_setup: MakeApp) -> None: with pytest.raises( RuntimeError, match=r"`scanpydoc.elegant_typehints` requires `sphinx.ext.autodoc`", diff --git a/tests/test_release_notes.py b/tests/test_release_notes.py index f39d09e..cb8826f 100644 --- a/tests/test_release_notes.py +++ b/tests/test_release_notes.py @@ -5,6 +5,7 @@ from types import MappingProxyType from typing import TYPE_CHECKING from textwrap import dedent +from functools import partial import pytest from sphinx.errors import SphinxWarning @@ -14,11 +15,14 @@ if TYPE_CHECKING: - from typing import TypeAlias + from typing import Literal, TypeAlias from pathlib import Path - from collections.abc import Mapping, Callable + from collections.abc import Mapping from sphinx.application import Sphinx + from sphinx.testing.util import SphinxTestApp + + from scanpydoc.testing import MakeApp Tree: TypeAlias = Mapping[str | Path, "Tree | str"] @@ -33,13 +37,19 @@ def mkfiles(root: Path, tree: Tree = MappingProxyType({})) -> None: @pytest.fixture(params=["rst", "myst"]) -def app( - request: pytest.FixtureRequest, make_app_setup: Callable[..., Sphinx] -) -> Sphinx: - return make_app_setup( +def file_format(request: pytest.FixtureRequest) -> Literal["rst", "myst"]: + return request.param # type: ignore[no-any-return] + + +@pytest.fixture +def make_app_relnotes( + make_app_setup: MakeApp, file_format: Literal["rst", "myst"] +) -> MakeApp: + return partial( + make_app_setup, "pseudoxml", extensions=[ - *(["myst_parser"] if request.param == "myst" else []), + *(["myst_parser"] if file_format == "myst" else []), "scanpydoc.release_notes", ], exclude_patterns=["[!i]*.md"], @@ -47,23 +57,30 @@ def app( @pytest.fixture -def index_filename(app: Sphinx) -> str: - return "index.md" if "myst_parser" in app.extensions else "index.rst" +def app(make_app_relnotes: MakeApp) -> SphinxTestApp: + return make_app_relnotes() + + +@pytest.fixture +def index_filename(file_format: Literal["rst", "myst"]) -> str: + return "index.md" if file_format == "myst" else "index.rst" @pytest.fixture -def index_template(app: Sphinx) -> str: +def index_template(file_format: Literal["rst", "myst"]) -> str: return ( "```{{release-notes}} {}\n```" - if "myst_parser" in app.extensions + if file_format == "myst" else ".. release-notes:: {}" ) @pytest.fixture -def files(app: Sphinx, index_filename: str, index_template: str) -> Tree: +def files( + file_format: Literal["rst", "myst"], index_filename: str, index_template: str +) -> Tree: files: Tree - if "myst_parser" in app.extensions: + if file_format == "myst": files = { index_filename: index_template.format("."), "1.2.0.md": "(v1.2.0)=\n### 1.2.0", @@ -126,15 +143,15 @@ def test_release_notes(tmp_path: Path, app: Sphinx, files: Tree) -> None: ) def test_error_wrong_file( tmp_path: Path, - app: Sphinx, + make_app_relnotes: MakeApp, index_filename: str, index_template: str, root: str, files: Tree, pattern: str, ) -> None: + app = make_app_relnotes(exception_on_warning=True) mkfiles(tmp_path, {index_filename: index_template.format(root), **files}) - app.warningiserror = True with pytest.raises(SphinxWarning, match=pattern): app.build() @@ -142,12 +159,12 @@ def test_error_wrong_file( def test_error_no_src( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, - app: Sphinx, + make_app_relnotes: MakeApp, files: Tree, ) -> None: + app = make_app_relnotes(exception_on_warning=True) if "myst_parser" not in app.extensions: pytest.skip("rst parser doesn’t need this") - app.warningiserror = True rn, _ = directives.directive("release-notes", get_language("en"), new_document("")) monkeypatch.setattr(rn, "get_source_info", lambda *_, **__: ("", 0)) diff --git a/tests/test_rtd_github_links.py b/tests/test_rtd_github_links.py index 84b253e..f2664e5 100644 --- a/tests/test_rtd_github_links.py +++ b/tests/test_rtd_github_links.py @@ -28,9 +28,9 @@ from typing import Literal from collections.abc import Callable - from sphinx.application import Sphinx from _pytest.monkeypatch import MonkeyPatch + from scanpydoc.testing import MakeApp from scanpydoc.rtd_github_links._linkcode import Domain, DomainInfo @@ -111,7 +111,7 @@ def test_infer_vars_error(config: Config, setup: list[str], pattern: str) -> Non _infer_vars(config) -def test_app(monkeypatch: MonkeyPatch, make_app_setup: Callable[..., Sphinx]) -> None: +def test_app(monkeypatch: MonkeyPatch, make_app_setup: MakeApp) -> None: filters: dict[str, Callable[..., object]] = {} monkeypatch.setattr("scanpydoc.rtd_github_links._init_vars", lambda *_: None) monkeypatch.setattr("scanpydoc.rtd_github_links.DEFAULT_FILTERS", filters)