diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b83692d..a753bf9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,7 @@ concurrency: env: PIP_ROOT_USER_ACTION: ignore + COLUMNS: 200 jobs: build: diff --git a/.gitignore b/.gitignore index cdefbc8..f0b62f1 100644 --- a/.gitignore +++ b/.gitignore @@ -6,9 +6,9 @@ _version.py /.python-version # Testing -/.coverage +/.coverage* /coverage.* -/.pytest_cache/ +/.*cache/ # Docs /docs/_build/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 15bc650..baf2d03 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.1.9 + rev: v0.1.11 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] @@ -16,3 +16,11 @@ repos: additional_dependencies: - prettier@3.0.2 - prettier-plugin-jinja-template@1.0.0 + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.8.0 + hooks: + - id: mypy + additional_dependencies: + - sphinx + - pytest + - types-docutils diff --git a/.vscode/settings.json b/.vscode/settings.json index 694223f..2a7e318 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,5 @@ { - "python.testing.pytestArgs": ["-v", "--color=yes"], + "python.testing.pytestArgs": ["-vv", "--color=yes"], "python.testing.unittestEnabled": false, "python.testing.pytestEnabled": true, "python.terminal.activateEnvironment": false, @@ -7,8 +7,8 @@ "editor.defaultFormatter": "charliermarsh.ruff", "editor.formatOnSave": true, "editor.codeActionsOnSave": { - "source.fixAll": true, - "source.organizeImports": true, + "source.fixAll": "explicit", + "source.organizeImports": "explicit", }, }, } diff --git a/docs/conf.py b/docs/conf.py index 8c6b0cc..bbaad23 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -2,11 +2,11 @@ from __future__ import annotations +from typing import TYPE_CHECKING +from pathlib import PurePosixPath from datetime import datetime from datetime import timezone as tz from importlib.metadata import metadata -from pathlib import PurePosixPath -from typing import TYPE_CHECKING if TYPE_CHECKING: diff --git a/pyproject.toml b/pyproject.toml index 6ee3bb1..3b9550c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,7 @@ theme = [ [project.entry-points.'sphinx.html_themes'] scanpydoc = 'scanpydoc.theme' -[tool.ruff] +[tool.ruff.lint] select = ['ALL'] allowed-confusables = ['’', '×', 'l'] ignore = [ @@ -57,8 +57,10 @@ ignore = [ 'D407', # We’re not using Numpydoc style 'FIX002', # TODOs are OK 'PD', # False positives + 'COM812', # Conflicts with formatting + 'ISC001', # Conflicts with formatting ] -[tool.ruff.per-file-ignores] +[tool.ruff.lint.per-file-ignores] 'example.py' = ['ALL'] 'docs/conf.py' = [ 'INP001', # `docs` is not a namespace package @@ -68,16 +70,25 @@ ignore = [ 'D103', # Test functions don’t need docstrings 'S101', # Pytest tests use `assert` ] - -[tool.ruff.isort] +[tool.ruff.lint.flake8-type-checking] +strict = true +[tool.ruff.lint.isort] +length-sort = true lines-after-imports = 2 known-first-party = ['scanpydoc'] +[tool.mypy] +strict = true +explicit_package_bases = true +mypy_path = ['$MYPY_CONFIG_FILE_DIR/src'] + [tool.hatch.version] source = 'vcs' [tool.hatch.build.hooks.vcs] version-file = 'src/scanpydoc/_version.py' +[tool.hatch.envs.default] +dependencies = ['types-docutils'] [tool.hatch.envs.docs] python = '3.11' features = ['doc'] @@ -101,6 +112,19 @@ addopts = [ '-psphinx.testing.fixtures', ] +[tool.coverage.run] +source_pkgs = ["scanpydoc"] +[tool.coverage.paths] +scanpydoc = ["src/scanpydoc"] +[tool.coverage.report] +exclude_lines = [ + "no cov", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", +] +[tool.coverage_rich] +fail-under = 70 + [build-system] requires = ['hatchling', 'hatch-vcs'] build-backend = 'hatchling.build' diff --git a/src/scanpydoc/__init__.py b/src/scanpydoc/__init__.py index ea496dc..9730173 100644 --- a/src/scanpydoc/__init__.py +++ b/src/scanpydoc/__init__.py @@ -4,15 +4,14 @@ """ from __future__ import annotations +from typing import TYPE_CHECKING, Any, TypeVar from textwrap import indent -from typing import TYPE_CHECKING, Any +from collections.abc import Callable from ._version import __version__ if TYPE_CHECKING: - from collections.abc import Callable - from sphinx.application import Sphinx @@ -28,9 +27,11 @@ :ref:`Metadata ` for this extension. """ +C = TypeVar("C", bound=Callable[..., Any]) + -def _setup_sig(fn: Callable) -> Callable: - fn.__doc__ += "\n\n" + indent(setup_sig_str, " " * 4) +def _setup_sig(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 ea79180..acca3dd 100644 --- a/src/scanpydoc/autosummary_generate_imported.py +++ b/src/scanpydoc/autosummary_generate_imported.py @@ -13,13 +13,13 @@ from __future__ import annotations import logging -from pathlib import Path from typing import TYPE_CHECKING, Any +from pathlib import Path from sphinx.ext import autosummary from sphinx.ext.autosummary.generate import generate_autosummary_docs -from . import _setup_sig, metadata +from . import metadata, _setup_sig if TYPE_CHECKING: @@ -35,7 +35,7 @@ def _generate_stubs(app: Sphinx) -> None: if gen_files and not hasattr(gen_files, "__len__"): env = app.builder.env gen_files = [ - env.doc2path(x, base=None) + env.doc2path(x, base=False) for x in env.found_docs if Path(env.doc2path(x)).is_file() ] @@ -54,9 +54,6 @@ def _generate_stubs(app: Sphinx) -> None: generate_autosummary_docs( gen_files, - builder=app.builder, - warn=logger.warning, - info=logger.info, suffix=suffix, base_path=app.srcdir, imported_members=True, diff --git a/src/scanpydoc/definition_list_typed_field.py b/src/scanpydoc/definition_list_typed_field.py index 707e6a5..76a17b5 100644 --- a/src/scanpydoc/definition_list_typed_field.py +++ b/src/scanpydoc/definition_list_typed_field.py @@ -7,13 +7,13 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast -from docutils import nodes from sphinx import addnodes +from docutils import nodes from sphinx.domains.python import PyObject, PyTypedField -from . import _setup_sig, metadata +from . import metadata, _setup_sig if TYPE_CHECKING: @@ -37,26 +37,23 @@ class DLTypedField(PyTypedField): #: Override the list type list_type = nodes.definition_list - def make_field( + def make_field( # type: ignore[override] self, types: dict[str, list[nodes.Node]], domain: str, items: tuple[str, list[nodes.inline]], env: BuildEnvironment | None = None, - **kw, # noqa: ANN003 + **kw: Any, # noqa: ANN401 ) -> nodes.field: """Render a field to a documenttree node representing a definition list item.""" def make_refs( - role_name: str, - name: str, - node: type[TextLikeNode], + role_name: str, name: str, node: type[TextLikeNode] ) -> list[nodes.Node]: return self.make_xrefs(role_name, domain, name, node, env=env, **kw) def handle_item( - fieldarg: str, - content: list[nodes.inline], + fieldarg: str, content: list[nodes.inline] ) -> nodes.definition_list_item: term = nodes.term() term += make_refs(self.rolename, fieldarg, addnodes.literal_strong) @@ -64,7 +61,7 @@ def handle_item( field_type = types.pop(fieldarg, None) if field_type is not None: if len(field_type) == 1 and isinstance(field_type[0], nodes.Text): - (text_node,) = field_type # type: nodes.Text + [text_node] = cast(tuple[nodes.Text], field_type) classifier_content = make_refs( self.typerolename, text_node.astext(), diff --git a/src/scanpydoc/elegant_typehints/__init__.py b/src/scanpydoc/elegant_typehints/__init__.py index 953a54b..0dbab54 100644 --- a/src/scanpydoc/elegant_typehints/__init__.py +++ b/src/scanpydoc/elegant_typehints/__init__.py @@ -47,16 +47,16 @@ def x() -> Tuple[int, float]: from __future__ import annotations +from typing import TYPE_CHECKING, Any +from pathlib import Path +from functools import partial from collections import ChainMap from dataclasses import dataclass -from functools import partial -from pathlib import Path -from typing import TYPE_CHECKING, Any -from docutils.parsers.rst import roles from sphinx.ext.autodoc import ClassDocumenter +from docutils.parsers.rst import roles -from scanpydoc import _setup_sig, metadata +from scanpydoc import metadata, _setup_sig from .example import example_func @@ -64,8 +64,8 @@ def x() -> Tuple[int, float]: if TYPE_CHECKING: from collections.abc import Callable - from sphinx.application import Sphinx from sphinx.config import Config + from sphinx.application import Sphinx __all__ = ["example_func", "setup"] @@ -96,12 +96,12 @@ def _init_vars(_app: Sphinx, config: Config) -> None: raise ValueError(msg) if config.typehints_defaults is None and config.annotate_defaults: # override default for “typehints_defaults” - config.typehints_defaults = "braces" + config.typehints_defaults = "braces" # type: ignore[attr-defined] @dataclass class PickleableCallable: - func: Callable + func: Callable[..., Any] __call__ = property(lambda self: self.func) @@ -124,7 +124,7 @@ def setup(app: Sphinx) -> dict[str, Any]: from ._autodoc_patch import dir_head_adder - ClassDocumenter.add_directive_header = dir_head_adder( + ClassDocumenter.add_directive_header = dir_head_adder( # type: ignore[method-assign,assignment] qualname_overrides, ClassDocumenter.add_directive_header, ) diff --git a/src/scanpydoc/elegant_typehints/_autodoc_patch.py b/src/scanpydoc/elegant_typehints/_autodoc_patch.py index a5411ab..0ecfd22 100644 --- a/src/scanpydoc/elegant_typehints/_autodoc_patch.py +++ b/src/scanpydoc/elegant_typehints/_autodoc_patch.py @@ -1,14 +1,14 @@ from __future__ import annotations -from functools import wraps from typing import TYPE_CHECKING +from functools import wraps if TYPE_CHECKING: - from collections.abc import Callable, Mapping + from collections.abc import Mapping, Callable - from docutils.statemachine import StringList from sphinx.ext.autodoc import ClassDocumenter + from docutils.statemachine import StringList def dir_head_adder( @@ -41,9 +41,7 @@ def add_directive_header(self: ClassDocumenter, sig: str) -> None: def replace_multi_suffix( - lines: StringList, - old: tuple[str, str], - new: tuple[str, str], + lines: StringList, old: tuple[str, str], new: tuple[str, str] ) -> None: if len(old) != len(new) != 2: # noqa: PLR2004 msg = "Only supports replacing 2 lines" diff --git a/src/scanpydoc/elegant_typehints/_formatting.py b/src/scanpydoc/elegant_typehints/_formatting.py index a3f54e7..e77a5fa 100644 --- a/src/scanpydoc/elegant_typehints/_formatting.py +++ b/src/scanpydoc/elegant_typehints/_formatting.py @@ -1,18 +1,19 @@ from __future__ import annotations +import sys import inspect -from typing import TYPE_CHECKING, Any, get_origin +from typing import TYPE_CHECKING, Any, cast, get_origin -try: +if sys.version_info >= (3, 10): from types import UnionType -except ImportError: +else: UnionType = None from docutils import nodes +from docutils.utils import unescape from docutils.parsers.rst.roles import set_classes -from docutils.parsers.rst.states import Inliner, Struct -from docutils.utils import SystemMessage, unescape +from docutils.parsers.rst.states import Struct from scanpydoc import elegant_typehints @@ -20,8 +21,10 @@ if TYPE_CHECKING: from collections.abc import Iterable, Sequence - from docutils.nodes import Node from sphinx.config import Config + from docutils.nodes import Node + from docutils.utils import SystemMessage + from docutils.parsers.rst.states import Inliner def typehints_formatter(annotation: type[Any], config: Config) -> str | None: @@ -52,7 +55,10 @@ def typehints_formatter(annotation: type[Any], config: Config) -> str | None: try: full_name = f"{annotation.__module__}.{annotation.__qualname__}" except AttributeError: - full_name = f"{origin.__module__}.{origin.__qualname__}" + if origin is not None: + full_name = f"{origin.__module__}.{origin.__qualname__}" + else: + return None override = elegant_typehints.qualname_overrides.get(full_name) role = "exc" if issubclass(annotation_cls, BaseException) else "class" if override is not None: @@ -80,16 +86,16 @@ def _role_annot( # noqa: PLR0913 options["classes"] = options.get("classes", []).copy() options["classes"].extend(additional_classes) memo = Struct( - document=inliner.document, - reporter=inliner.reporter, - language=inliner.language, + document=inliner.document, # type: ignore[attr-defined] + reporter=inliner.reporter, # type: ignore[attr-defined] + language=inliner.language, # type: ignore[attr-defined] ) node = nodes.inline(unescape(rawtext), "", **options) - children, messages = inliner.parse(_unescape(text), lineno, memo, node) + children, messages = inliner.parse(_unescape(text), lineno, memo, node) # type: ignore[attr-defined] node.extend(children) return [node], messages def _unescape(rst: str) -> str: # IDK why the [ part is necessary. - return unescape(rst).replace("\\`", "`").replace("[", "\\[") + return cast(str, unescape(rst)).replace("\\`", "`").replace("[", "\\[") diff --git a/src/scanpydoc/elegant_typehints/_return_tuple.py b/src/scanpydoc/elegant_typehints/_return_tuple.py index 6c1690c..d0bf35c 100644 --- a/src/scanpydoc/elegant_typehints/_return_tuple.py +++ b/src/scanpydoc/elegant_typehints/_return_tuple.py @@ -1,22 +1,23 @@ from __future__ import annotations -import inspect import re -from logging import getLogger +import sys +import inspect from typing import TYPE_CHECKING, Any, Union, get_args, get_origin, get_type_hints from typing import Tuple as t_Tuple # noqa: UP035 +from logging import getLogger -try: +if sys.version_info > (3, 10): from types import UnionType -except ImportError: +else: UnionType = None from sphinx_autodoc_typehints import format_annotation if TYPE_CHECKING: - from collections.abc import Collection + from collections.abc import Sequence from sphinx.application import Sphinx from sphinx.ext.autodoc import Options @@ -75,10 +76,10 @@ def process_docstring( # noqa: PLR0913 lines[l : l + 1] = [f"{lines[l]} : {typ}"] -def _get_idxs_ret_names(lines: Collection[str]) -> list[int]: +def _get_idxs_ret_names(lines: Sequence[str]) -> list[int]: # Get return section i_prefix = None - l_start = None + l_start = 0 for l, line in enumerate(lines): if i_prefix is None: m = re_ret.match(line) diff --git a/src/scanpydoc/elegant_typehints/example.py b/src/scanpydoc/elegant_typehints/example.py index bbf5e15..e721a6c 100644 --- a/src/scanpydoc/elegant_typehints/example.py +++ b/src/scanpydoc/elegant_typehints/example.py @@ -12,3 +12,4 @@ def example_func(a: str | None, b: str | int | None = None) -> dict[str, int]: Returns: An example return value """ + return {} diff --git a/src/scanpydoc/rtd_github_links/__init__.py b/src/scanpydoc/rtd_github_links/__init__.py index 1382b12..edf018a 100644 --- a/src/scanpydoc/rtd_github_links/__init__.py +++ b/src/scanpydoc/rtd_github_links/__init__.py @@ -58,22 +58,22 @@ """ from __future__ import annotations -import inspect import sys -from importlib import import_module -from pathlib import Path, PurePosixPath +import inspect from types import ModuleType from typing import TYPE_CHECKING, Any +from pathlib import Path, PurePosixPath +from importlib import import_module -from jinja2.defaults import DEFAULT_FILTERS +from jinja2.defaults import DEFAULT_FILTERS # type: ignore[attr-defined] -from scanpydoc import _setup_sig, metadata +from scanpydoc import metadata, _setup_sig if TYPE_CHECKING: - from collections.abc import Callable - from types import CodeType, FrameType, FunctionType, MethodType, TracebackType + from types import CodeType, FrameType, MethodType, FunctionType, TracebackType from typing import TypeAlias + from collections.abc import Callable _SourceObjectType: TypeAlias = ( ModuleType @@ -86,12 +86,12 @@ | Callable[..., Any] ) - from sphinx.application import Sphinx from sphinx.config import Config + from sphinx.application import Sphinx -rtd_links_prefix: Path = None -github_base_url: str = None +rtd_links_prefix: PurePosixPath | None = None +github_base_url: str | None = None def _init_vars(_app: Sphinx, config: Config) -> None: @@ -110,13 +110,13 @@ def _init_vars(_app: Sphinx, config: Config) -> None: def _get_annotations(obj: _SourceObjectType) -> dict[str, Any]: - try: + if sys.version_info > (3, 10): from inspect import get_annotations - except ImportError: + else: from get_annotations import get_annotations try: - return get_annotations(obj) + return get_annotations(obj) # type: ignore[arg-type] except TypeError: return {} @@ -127,7 +127,7 @@ def _get_obj_module(qualname: str) -> tuple[Any, ModuleType]: Returns `None` as `obj` if it’s an annotated field without value. """ modname = qualname - attr_path = [] + attr_path: list[str] = [] while modname not in sys.modules: modname, leaf = modname.rsplit(".", 1) attr_path.insert(0, leaf) @@ -142,7 +142,7 @@ def _get_obj_module(qualname: str) -> tuple[Any, ModuleType]: except AttributeError as e: if is_dataclass(obj): thing = next(f for f in fields(obj) if f.name == attr_name) - elif attr_name in _get_annotations(obj): + elif obj is not None and attr_name in _get_annotations(obj): thing = None else: try: @@ -175,7 +175,7 @@ def _module_path(obj: _SourceObjectType, module: ModuleType) -> PurePosixPath: try: file = Path(inspect.getabsfile(obj)) except TypeError: - file = Path(module.__file__) + file = Path(module.__file__ or "") offset = -1 if file.name == "__init__.py" else 0 parts = module.__name__.split(".") return PurePosixPath(*file.parts[offset - len(parts) :]) @@ -197,6 +197,7 @@ def github_url(qualname: str) -> str: if hasattr(e, "__notes__"): e.__notes__.append(f"Qualname: {qualname!r}") raise + assert rtd_links_prefix is not None # noqa: S101 path = rtd_links_prefix / _module_path(obj, module) start, end = _get_linenos(obj) fragment = f"#L{start}-L{end}" if start and end else "" @@ -252,7 +253,7 @@ def setup(app: Sphinx) -> dict[str, Any]: if True: # test data - from dataclasses import dataclass, field, fields, is_dataclass + from dataclasses import field, fields, dataclass, is_dataclass @dataclass class _TestDataCls: diff --git a/src/scanpydoc/rtd_github_links/_linkcode.py b/src/scanpydoc/rtd_github_links/_linkcode.py index 18b48b0..52c78f1 100644 --- a/src/scanpydoc/rtd_github_links/_linkcode.py +++ b/src/scanpydoc/rtd_github_links/_linkcode.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Literal, TypedDict +from typing import Literal, TypedDict, cast, overload class PyInfo(TypedDict): @@ -19,6 +19,21 @@ class JSInfo(TypedDict): fullname: str +@overload +def linkcode_resolve(domain: Literal["py"], info: PyInfo) -> str | None: + ... + + +@overload +def linkcode_resolve(domain: Literal["c", "cpp"], info: CInfo) -> str | None: + ... + + +@overload +def linkcode_resolve(domain: Literal["javascript"], info: JSInfo) -> str | None: + ... + + def linkcode_resolve( domain: Literal["py", "c", "cpp", "javascript"], info: PyInfo | CInfo | JSInfo, @@ -27,6 +42,7 @@ def linkcode_resolve( if domain != "py": return None + info = cast(PyInfo, info) if not info["module"]: return None return github_url(f'{info["module"]}.{info["fullname"]}') diff --git a/src/scanpydoc/theme/__init__.py b/src/scanpydoc/theme/__init__.py index 704f3af..d223af4 100644 --- a/src/scanpydoc/theme/__init__.py +++ b/src/scanpydoc/theme/__init__.py @@ -70,8 +70,8 @@ from __future__ import annotations -from pathlib import Path from typing import TYPE_CHECKING +from pathlib import Path from scanpydoc import _setup_sig diff --git a/tests/conftest.py b/tests/conftest.py index 256cdc7..65bd20c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,30 +2,27 @@ from __future__ import annotations -import importlib.util -import linecache import sys -from textwrap import dedent -from typing import TYPE_CHECKING, Any +import linecache +import importlib.util from uuid import uuid4 +from typing import TYPE_CHECKING, Any +from textwrap import dedent import pytest if TYPE_CHECKING: - from collections.abc import Callable - from pathlib import Path from types import ModuleType + from pathlib import Path + from collections.abc import Callable, Generator - from docutils.nodes import document - from docutils.writers import Writer from sphinx.application import Sphinx @pytest.fixture() def make_app_setup( - make_app: Callable[..., Sphinx], - tmp_path: Path, + make_app: Callable[..., Sphinx], tmp_path: Path ) -> Callable[..., Sphinx]: def make_app_setup(**conf: Any) -> Sphinx: # noqa: ANN401 (tmp_path / "conf.py").write_text("") @@ -35,27 +32,16 @@ def make_app_setup(**conf: Any) -> Sphinx: # noqa: ANN401 @pytest.fixture() -def render() -> Callable[[Sphinx, document], str]: - def _render(app: Sphinx, doc: document) -> str: - # Doesn’t work as desc is an Admonition and the HTML writer doesn’t handle it - app.builder.prepare_writing({doc["source"]}) - writer: Writer = app.builder.docwriter - writer.document = doc - writer.document.settings = app.builder.docsettings - writer.translate() - return writer.output - - return _render - - -@pytest.fixture() -def make_module(tmp_path: Path) -> Callable[[str, str], ModuleType]: +def make_module( + tmp_path: Path, +) -> Generator[Callable[[str, str], ModuleType], None, None]: added_modules = [] def make_module(name: str, code: str) -> ModuleType: code = dedent(code) assert name not in sys.modules spec = importlib.util.spec_from_loader(name, loader=None) + assert spec is not None mod = sys.modules[name] = importlib.util.module_from_spec(spec) path = tmp_path / f"{name}_{str(uuid4()).replace('-', '_')}.py" path.write_text(code) diff --git a/tests/test_base.py b/tests/test_base.py index 708e4b6..bf4efa2 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -3,9 +3,9 @@ from __future__ import annotations import pkgutil +from typing import TYPE_CHECKING from functools import partial from importlib import import_module -from typing import TYPE_CHECKING import scanpydoc @@ -18,11 +18,10 @@ def test_all_get_installed( - monkeypatch: pytest.MonkeyPatch, - make_app_setup: Callable[..., Sphinx], + monkeypatch: pytest.MonkeyPatch, make_app_setup: Callable[..., Sphinx] ) -> None: - setups_seen = set() - setups_called = {} + setups_seen: set[str] = set() + setups_called: dict[str, Sphinx] = {} for _finder, mod_name, _ in pkgutil.walk_packages( scanpydoc.__path__, f"{scanpydoc.__name__}.", diff --git a/tests/test_definition_list_typed_field.py b/tests/test_definition_list_typed_field.py index 2863cd3..37a0feb 100644 --- a/tests/test_definition_list_typed_field.py +++ b/tests/test_definition_list_typed_field.py @@ -2,9 +2,11 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast import pytest +from sphinx import addnodes +from docutils import nodes from sphinx.testing.restructuredtext import parse @@ -41,7 +43,7 @@ def app(make_app_setup: Callable[..., Sphinx]) -> Sphinx: def test_apps_separate(app: Sphinx, make_app_setup: Callable[..., Sphinx]) -> None: app_no_setup = make_app_setup() - assert app is not make_app_setup + assert app is not app_no_setup assert "scanpydoc.definition_list_typed_field" in app.extensions assert "scanpydoc.definition_list_typed_field" not in app_no_setup.extensions @@ -55,17 +57,18 @@ def test_convert_params(app: Sphinx, code: str, n: int) -> None: # content_offset, block_text, state, state_machine, doc = parse(app, code) - assert doc[1].tagname == "desc" - assert doc[1]["desctype"] == "function" - assert doc[1][1].tagname == "desc_content" - assert doc[1][1][0].tagname == "field_list" - assert doc[1][1][0][0].tagname == "field" - assert doc[1][1][0][0][0].tagname == "field_name" - assert doc[1][1][0][0][0][0].astext() == "Parameters" - assert doc[1][1][0][0][1].tagname == "field_body" - assert doc[1][1][0][0][1][0].tagname == "definition_list" - - dl = doc[1][1][0][0][1][0] + assert (desc := cast(addnodes.desc, doc[1])).tagname == "desc" + assert desc["desctype"] == "function" + assert (descc := cast(addnodes.desc_content, desc[1])).tagname == "desc_content" + assert (fl := cast(nodes.field_list, descc[0])).tagname == "field_list" + assert (field := cast(nodes.field, fl[0])).tagname == "field" + assert (field_name := cast(nodes.field_name, field[0])).tagname == "field_name" + assert cast(nodes.Text, field_name[0]).astext() == "Parameters" + assert (field_body := cast(nodes.field_body, field[1])).tagname == "field_body" + assert ( + dl := cast(nodes.definition_list, field_body[0]) + ).tagname == "definition_list" + assert len(dl) == n, dl for dli in dl: assert dli.tagname == "definition_list_item" diff --git a/tests/test_elegant_typehints.py b/tests/test_elegant_typehints.py index 9a0bcee..4a568ab 100644 --- a/tests/test_elegant_typehints.py +++ b/tests/test_elegant_typehints.py @@ -2,19 +2,21 @@ from __future__ import annotations -import inspect import re -from collections.abc import Callable, Mapping -from pathlib import Path +import inspect +from io import StringIO from typing import ( TYPE_CHECKING, Any, + Union, AnyStr, NoReturn, Optional, - Union, + cast, get_origin, ) +from pathlib import Path +from collections.abc import Mapping, Callable import pytest import sphinx_autodoc_typehints as sat @@ -24,15 +26,16 @@ if TYPE_CHECKING: - from types import FunctionType, ModuleType + from types import ModuleType from sphinx.application import Sphinx +NONE_RTYPE = ":rtype: :sphinx_autodoc_typehints_type:`\\:py\\:obj\\:\\`None\\``" + + @pytest.fixture() -def testmod( - make_module: Callable[[str, str], ModuleType], -) -> ModuleType: +def testmod(make_module: Callable[[str, str], ModuleType]) -> ModuleType: return make_module( "testmod", """\ @@ -64,9 +67,9 @@ def app(make_app_setup: Callable[..., Sphinx]) -> Sphinx: @pytest.fixture() -def process_doc(app: Sphinx) -> Callable[[FunctionType], list[str]]: - def process(fn: FunctionType) -> list[str]: - lines = inspect.getdoc(fn).split("\n") +def process_doc(app: Sphinx) -> Callable[[Callable[..., Any]], list[str]]: + def process(fn: Callable[..., Any]) -> list[str]: + lines = (inspect.getdoc(fn) or "").split("\n") sat.process_docstring(app, "function", fn.__name__, fn, None, lines) process_docstring(app, "function", fn.__name__, fn, None, lines) return lines @@ -96,22 +99,29 @@ def _escape_sat(rst: str) -> str: return f":sphinx_autodoc_typehints_type:`{rst}`" -def test_alternatives(process_doc: Callable[[FunctionType], list[str]]) -> None: - def fn_test(s: str): # noqa: ANN202, ARG001 +def test_alternatives(process_doc: Callable[[Callable[..., Any]], list[str]]) -> None: + def fn_test(s: str) -> None: # pragma: no cover """:param s: Test""" + del s assert process_doc(fn_test) == [ f":type s: {_escape_sat(':py:class:`str`')}", ":param s: Test", + NONE_RTYPE, ] -def test_defaults_simple(process_doc: Callable[[FunctionType], list[str]]) -> None: - def fn_test(s: str = "foo", n: None = None, i_: int = 1): # noqa: ANN202, ARG001 +def test_defaults_simple( + process_doc: Callable[[Callable[..., Any]], list[str]], +) -> None: + def fn_test( + s: str = "foo", n: None = None, i_: int = 1 + ) -> None: # pragma: no cover r""":param s: Test S :param n: Test N :param i\_: Test I """ # noqa: D205 + del s, n, i_ assert process_doc(fn_test) == [ f":type s: {_escape_sat(':py:class:`str`')} (default: ``'foo'``)", @@ -120,12 +130,16 @@ def fn_test(s: str = "foo", n: None = None, i_: int = 1): # noqa: ANN202, ARG00 ":param n: Test N", rf":type i\_: {_escape_sat(':py:class:`int`')} (default: ``1``)", r":param i\_: Test I", + NONE_RTYPE, ] -def test_defaults_complex(process_doc: Callable[[FunctionType], list[str]]) -> None: - def fn_test(m: Mapping[str, int] = {}): # noqa: ANN202, ARG001 +def test_defaults_complex( + process_doc: Callable[[Callable[..., Any]], list[str]], +) -> None: + def fn_test(m: Mapping[str, int] = {}) -> None: # pragma: no cover """:param m: Test M""" + del m expected = ( r":py:class:`~collections.abc.Mapping`\ \[:py:class:`str`, :py:class:`int`]" @@ -133,6 +147,7 @@ def fn_test(m: Mapping[str, int] = {}): # noqa: ANN202, ARG001 assert process_doc(fn_test) == [ f":type m: {_escape_sat(expected)} (default: ``{{}}``)", ":param m: Test M", + NONE_RTYPE, ] @@ -159,11 +174,8 @@ def test_qualname_overrides_exception(app: Sphinx, testmod: ModuleType) -> None: ], ids=lambda p: str(p).replace("typing.", ""), ) -def test_typing_classes( - app: Sphinx, - annotation: type, -) -> None: - app.config.typehints_fully_qualified = True +def test_typing_classes(app: Sphinx, annotation: type) -> None: + app.config.typehints_fully_qualified = True # type: ignore[attr-defined] name = ( getattr(annotation, "_name", None) or getattr(annotation, "__name__", None) @@ -197,7 +209,7 @@ def test_autodoc( ) app.build() out = Path(app.outdir, "index.html").read_text() - assert not app._warning.getvalue(), app._warning.getvalue() # noqa: SLF001 + assert not (ws := cast(StringIO, app._warning).getvalue()), ws # noqa: SLF001 assert re.search( r'<(code|span)?[^>]*>test\.' f'<(code|span)?[^>]*>{sub}', @@ -235,9 +247,10 @@ class B: app.build() out = Path(app.outdir, "index.html").read_text() + buf = cast(StringIO, app._warning) # noqa: SLF001 warnings = [ w - for w in app._warning.getvalue().splitlines() # noqa: SLF001 + for w in buf.getvalue().splitlines() if "Cannot treat a function defined as a local function" not in w ] assert not warnings, warnings @@ -281,12 +294,12 @@ class B: ids=["tuple", "Optional[Tuple]", "Complex"], ) def test_return( - process_doc: Callable[[FunctionType], list[str]], + process_doc: Callable[[Callable[..., Any]], list[str]], docstring: str, return_ann: type, foo_rendered: str, ) -> None: - def fn_test(): # noqa: ANN202 + def fn_test() -> None: # pragma: no cover pass fn_test.__doc__ = docstring diff --git a/tests/test_rtd_github_links.py b/tests/test_rtd_github_links.py index 3eb1d0e..72e07b8 100644 --- a/tests/test_rtd_github_links.py +++ b/tests/test_rtd_github_links.py @@ -1,13 +1,13 @@ """Test rtd_github_links subextension.""" -from dataclasses import Field -from importlib import import_module from pathlib import Path, PurePosixPath +from importlib import import_module +from dataclasses import Field import pytest from _pytest.monkeypatch import MonkeyPatch -from scanpydoc.rtd_github_links import _get_linenos, _get_obj_module, github_url +from scanpydoc.rtd_github_links import github_url, _get_linenos, _get_obj_module HERE = Path(__file__).parent @@ -20,9 +20,7 @@ def _env(monkeypatch: MonkeyPatch) -> None: @pytest.fixture(params=[".", "src"]) def prefix( - monkeypatch: MonkeyPatch, - _env: None, - request: pytest.FixtureRequest, + monkeypatch: MonkeyPatch, _env: None, request: pytest.FixtureRequest ) -> PurePosixPath: pfx = PurePosixPath(request.param) monkeypatch.setattr("scanpydoc.rtd_github_links.rtd_links_prefix", pfx) @@ -43,10 +41,7 @@ def prefix( ], ) def test_as_function( - prefix: PurePosixPath, - module: str, - name: str, - obj_path: str, + prefix: PurePosixPath, module: str, name: str, obj_path: str ) -> None: assert github_url(f"scanpydoc.{module}") == str(prefix / module / "__init__.py") obj = getattr(import_module(f"scanpydoc.{module}"), name)