From 78ba30153602b139d91b01ee6311def8956f52c7 Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Tue, 20 Oct 2026 11:19:57 +0200 Subject: [PATCH 1/3] ci: require changelog entry --- .github/workflows/pr.yaml | 19 ++++++ CHANGELOG.md | 10 ++- pyproject.toml | 4 +- scripts/check_changelog.py | 99 +++++++++++++++++++++++++++++ src/scverse_doc/autodoc/__init__.py | 4 +- src/scverse_doc/config.py | 4 +- src/scverse_doc/registry.py | 17 ++++- src/scverse_doc/source.py | 11 ++-- src/scverse_doc/theme/__init__.py | 6 +- src/scverse_doc/typed_fields.py | 12 +++- src/scverse_doc/xref_overrides.py | 5 +- 11 files changed, 165 insertions(+), 26 deletions(-) create mode 100644 .github/workflows/pr.yaml create mode 100644 scripts/check_changelog.py diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml new file mode 100644 index 0000000..679084c --- /dev/null +++ b/.github/workflows/pr.yaml @@ -0,0 +1,19 @@ +name: PR +on: + pull_request: + types: [opened, synchronize, reopened, edited] +permissions: + contents: read +jobs: + check-changelog: + name: Check changelog + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + - run: uv run scripts/check_changelog.py --pr="$PR" --title="$TITLE" + env: + PR: ${{ github.event.pull_request.number }} + TITLE: ${{ github.event.pull_request.title }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 2009b8f..eb658de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,17 +10,21 @@ and this project adheres to [Semantic Versioning][]. ## [Unreleased] +### Fixed + +- make logo and search button fill sidebar (#67) + ## [0.2.1] ### Fixed -- Hiding private base classes no longer crashes when a base is a string or has no `__name__` +- Hiding private base classes no longer crashes when a base is a string or has no `__name__` (#63) ## [0.2.0] ### Added -- Annotate prose return sections’ items with the members of a returned tuple +- Annotate prose return sections’ items with the members of a returned tuple (#56) - `release-notes` directive, ported from `scanpydoc` (#51) - Parameters and attributes as definition lists, ported from `scanpydoc`’s `definition_list_typed_field` (#54) - `xref_overrides`, ported from `scanpydoc`’s `qualname_overrides`, also for Sphinx’ own annotations, and reporting unused entries (#53) @@ -29,7 +33,7 @@ and this project adheres to [Semantic Versioning][]. ### Fixed -- Handle attributes (annotated fields on classes) +- Handle attributes, i.e annotated fields on classes (#47) ## [0.1.0] diff --git a/pyproject.toml b/pyproject.toml index 58529c2..3cf9f01 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,7 +62,7 @@ doc = [ "sphinx-autodoc-typehints", "sphinxcontrib-bibtex>=1", ] -typecheck = [ "mypy", "types-docutils" ] +typecheck = [ "mypy", "types-docutils", "wenmode" ] # wenmode for scripts/check_changelog.py [tool.hatch] version.source = "vcs" @@ -159,6 +159,8 @@ lint.pydocstyle.convention = "numpy" files = [ "docs", "scripts", "src", "tests" ] # Fixture conf.py files all share the module name "conf". exclude = [ "tests/roots/" ] +disallow_any_explicit = true +disallow_any_generics = true [tool.pytest] addopts = [ diff --git a/scripts/check_changelog.py b/scripts/check_changelog.py new file mode 100644 index 0000000..2492a57 --- /dev/null +++ b/scripts/check_changelog.py @@ -0,0 +1,99 @@ +#!/usr/bin/env -S uv run --script +# SPDX-License-Identifier: MPL-2.0 +# /// script +# requires-python = ">=3.12" +# dependencies = ["wenmode"] +# /// +"""Check that a PR’s changelog entry is in the section its conventional-commit title implies. + +The entry is a list item under `## [Unreleased]` › `###
` that ends with the PR number, +e.g. `- Some fix (#67)`. +PR titles with types that don’t map to a section (`ci:`, `docs:`, …) pass unchecked. + +Usage +----- + uv run scripts/check_changelog.py --pr 67 --title 'fix: make logo fill sidebar' +""" + +from __future__ import annotations + +import re +import sys +from argparse import ArgumentParser +from pathlib import Path +from typing import TYPE_CHECKING + +from wenmode import Wenmode +from wenmode.nodes import Heading, List, ListItem, Literal, Paragraph, Text + +if TYPE_CHECKING: + from collections.abc import Generator + +SECTIONS = {"feat": "Added", "fix": "Fixed", "perf": "Changed"} + + +def find_entries(changelog: str, pr: int) -> Generator[tuple[int, str | None, str | None]]: + """Find list items ending in `(#)` as `(line, h2, h3)`.""" + suffix = f"(#{pr})" + h2: str | None = None + h3: str | None = None + for node in Wenmode(positions=True).parse(changelog).children: + match node: + case Heading(depth=2 | 3 as depth, children=children): + text = "".join(c.value for c in children if isinstance(c, Literal)) + h2, h3 = (text, None) if depth == 2 else (h2, text) + case List(children=items): + for item in items: + match item: + case ListItem(children=[Paragraph(children=[*rest, Text(value=value)]), *_]): + # an item consisting only of the PR number is a placeholder, not an entry + if value.endswith(suffix) and (rest or value != suffix): + assert item.position is not None + yield (changelog.count("\n", 0, item.position.start) + 1, h2, h3) + + +def check(changelog: str, pr: int, title: str) -> tuple[int | None, str] | None: + """Return `(line, message)` describing the problem, or `None` if the entry is fine.""" + if not (m := re.match(r"(\w+)(?:\([^)]*\))?!?:", title)) or not (want := SECTIONS.get(m[1])): + return None + entries = list(find_entries(changelog, pr)) + if any(h2 == "[Unreleased]" and h3 == want for _, h2, h3 in entries): + return None + if not entries: + return None, f"No list item ending in “(#{pr})”; “{title}” needs one under “## [Unreleased]” › “### {want}”" + line, h2, h3 = entries[0] + return ( + line, + f"Entry for #{pr} is under “## {h2}” › “### {h3}”, but “{title}” needs “## [Unreleased]” › “### {want}”", + ) + + +def main() -> int: + """Return 0 if the entry is in the right section, else print a GitHub annotation and return 1.""" + parser = ArgumentParser(description=__doc__) + parser.add_argument("--pr", type=int, required=True) + parser.add_argument("--title", required=True) + parser.add_argument("--changelog", type=Path, default=Path("CHANGELOG.md")) + args = parser.parse_args() + + _self_check() + if (problem := check(args.changelog.read_text(), args.pr, args.title)) is None: + return 0 + line, msg = problem + loc = f"file={args.changelog}{f',line={line}' if line else ''}" + print(f"::error {loc}::{msg}") + return 1 + + +def _self_check() -> None: + cl = "## [Unreleased]\n\n### Fixed\n\n- A `fix` (#1)\n- (#2)\n\n## [0.1.0]\n\n### Added\n\n- Old (#3)\n" + assert check(cl, 1, "fix(x)!: y") is None + assert check(cl, 1, "ci: y") is None + assert check(cl, 1, "feat: y") == (5, "Entry for #1 is under “## [Unreleased]” › “### Fixed”, but “feat: y” needs “## [Unreleased]” › “### Added”") # fmt: skip + assert check(cl, 2, "fix: y") is not None # placeholder + assert (r := check(cl, 3, "feat: y")) is not None and r[0] == 12 # wrong release + assert check(cl, 4, "fix: y") is not None + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/scverse_doc/autodoc/__init__.py b/src/scverse_doc/autodoc/__init__.py index 780edcb..f82f311 100644 --- a/src/scverse_doc/autodoc/__init__.py +++ b/src/scverse_doc/autodoc/__init__.py @@ -22,8 +22,6 @@ from sphinx.util.typing import ExtensionMetadata if TYPE_CHECKING: - from typing import Any - from sphinx.application import Sphinx from sphinx.config import Config @@ -51,7 +49,7 @@ def _member_type(obj_path: str) -> Literal["method", "property", "attribute"]: return "attribute" -def _skip_private_bases(app: Sphinx, name: str, obj: type, options: Any, bases: list[type | str]) -> None: +def _skip_private_bases(app: Sphinx, name: str, obj: type, options: object, bases: list[type | str]) -> None: # Other handlers may have added strings, and some typing constructs have no `__name__`. bases[:] = [ b diff --git a/src/scverse_doc/config.py b/src/scverse_doc/config.py index 5489f78..30a30d9 100644 --- a/src/scverse_doc/config.py +++ b/src/scverse_doc/config.py @@ -7,7 +7,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from sphinx.util.typing import ExtensionMetadata @@ -41,7 +41,7 @@ ) #: Sphinx config values applied unless `conf.py` sets them. -DEFAULTS: dict[str, Any] = { +DEFAULTS: dict[str, object] = { "autosummary_generate": True, "autodoc_member_order": "groupwise", "napoleon_google_docstring": False, diff --git a/src/scverse_doc/registry.py b/src/scverse_doc/registry.py index 5d8a145..e9b047d 100644 --- a/src/scverse_doc/registry.py +++ b/src/scverse_doc/registry.py @@ -22,7 +22,7 @@ from importlib.resources import files from operator import itemgetter from pathlib import Path -from typing import TYPE_CHECKING, Any, Literal, cast +from typing import TYPE_CHECKING, Literal, NotRequired, TypedDict, cast from sphinx.application import Sphinx # noqa: TC002 # `_build_cache`’s annotations must resolve at runtime from sphinx.util import requests @@ -106,8 +106,19 @@ class Package: """The package’s brand accent, falling back to the scverse deep blue.""" +class _Entry(TypedDict): + """The fields of an upstream listing entry that the registry reads.""" + + name: str + documentation_home: str + inventory: NotRequired[str] + project_home: NotRequired[str] + description: NotRequired[str] + category: NotRequired[str] + + @cache -def _fetch() -> list[dict[str, Any]]: +def _fetch() -> list[_Entry]: """Fetch the upstream listing, going through `_cache_dir` if one is set.""" cached = _cache_dir / "scverse-packages.json" if _cache_dir is not None else None if cached is not None and cached.is_file() and time.time() - cached.stat().st_mtime < _MAX_AGE_SECONDS: @@ -122,7 +133,7 @@ def _fetch() -> list[dict[str, Any]]: if cached is not None: cached.parent.mkdir(parents=True, exist_ok=True) cached.write_bytes(response.content) - listing: list[dict[str, Any]] = response.json() + listing: list[_Entry] = response.json() return listing diff --git a/src/scverse_doc/source.py b/src/scverse_doc/source.py index dd7d1a9..2cecdb8 100644 --- a/src/scverse_doc/source.py +++ b/src/scverse_doc/source.py @@ -20,7 +20,7 @@ from enum import Enum from importlib import import_module from pathlib import Path, PurePosixPath -from typing import TYPE_CHECKING, Any, NamedTuple, Self +from typing import TYPE_CHECKING, NamedTuple, Self from urllib.parse import urlsplit from sphinx.errors import ConfigError @@ -128,13 +128,13 @@ def _ref(config: Config) -> str: return "main" -def _theme_options(source: _Source) -> dict[str, Any]: +def _theme_options(source: _Source) -> dict[str, object]: """Every theme option some theme reads this from; only the declared ones are applied.""" if not source.provider: # Every one of these makes a theme build per-page URLs we have no layout for: # `sphinx-basic-ng` warns, `sphinx-book-theme` guesses the forge and errors out if it can’t. return {} - options: dict[str, Any] = { + options: dict[str, object] = { # sphinx-basic-ng, and so furo "source_repository": source.url, "source_branch": source.ref, @@ -155,7 +155,7 @@ def _theme_options(source: _Source) -> dict[str, Any]: return options -def _html_context(source: _Source) -> dict[str, Any]: +def _html_context(source: _Source) -> dict[str, object]: """The `html_context` entries `pydata-sphinx-theme`, `sphinx_rtd_theme` and `furo` read.""" parts = urlsplit(source.url) owner, _, name = parts.path.strip("/").rpartition("/") @@ -198,9 +198,10 @@ def _configure_theme(app: Sphinx) -> None: def _linkcode_url(source: _Source, module: str, fullname: str) -> str | None: """Link to a Python object’s definition, or :data:`None` if it has no findable source.""" try: - obj: Any = import_module(module) + obj = import_module(module) for part in filter(None, fullname.split(".")): obj = getattr(obj, part) + assert callable(obj) obj = inspect.unwrap(obj) lines, start = inspect.getsourcelines(obj) file = Path(inspect.getsourcefile(obj) or "") diff --git a/src/scverse_doc/theme/__init__.py b/src/scverse_doc/theme/__init__.py index 10e5e26..5d41aae 100644 --- a/src/scverse_doc/theme/__init__.py +++ b/src/scverse_doc/theme/__init__.py @@ -12,7 +12,7 @@ from __future__ import annotations from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from sphinx.util.typing import ExtensionMetadata @@ -97,14 +97,14 @@ def _add_ecosystem_context( app: Sphinx, pagename: str, templatename: str, - context: dict[str, Any], + context: dict[str, object], doctree: object, ) -> None: """Expose the registry to the scverse menu template.""" if app.config.html_theme != "scverse": return current = _package_name(app.config).lower() - groups: dict[str, list[dict[str, Any]]] = {"core": [], "ecosystem": []} + groups: dict[str, list[dict[str, object]]] = {"core": [], "ecosystem": []} for pkg in packages.values(): groups[pkg.kind].append({"name": pkg.name, "docs": pkg.docs, "current": pkg.name.lower() == current}) context["scverse_ecosystem"] = groups diff --git a/src/scverse_doc/typed_fields.py b/src/scverse_doc/typed_fields.py index 7c9f464..254e9c9 100644 --- a/src/scverse_doc/typed_fields.py +++ b/src/scverse_doc/typed_fields.py @@ -17,7 +17,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, ClassVar +from typing import TYPE_CHECKING, ClassVar, TypedDict, Unpack from docutils import nodes from sphinx import addnodes @@ -26,8 +26,8 @@ if TYPE_CHECKING: from collections.abc import Iterable - from typing import Any + from docutils.parsers.rst.states import Inliner from sphinx.application import Sphinx from sphinx.environment import BuildEnvironment @@ -36,6 +36,12 @@ __all__ = ["setup"] +class _XRefKw(TypedDict): + contnode: nodes.Node | None + inliner: Inliner | None + location: nodes.Node | None + + class _DLTypedField(PyTypedField): """A :class:`~sphinx.domains.python.PyTypedField` that renders its items as a definition list.""" @@ -47,7 +53,7 @@ def make_field( # type: ignore[override] domain: str, items: Iterable[tuple[str, list[nodes.inline]]], env: BuildEnvironment | None = None, - **kw: Any, + **kw: Unpack[_XRefKw], ) -> nodes.field: """Render a field to a node holding a definition list.""" diff --git a/src/scverse_doc/xref_overrides.py b/src/scverse_doc/xref_overrides.py index 2585df6..36dcfb1 100644 --- a/src/scverse_doc/xref_overrides.py +++ b/src/scverse_doc/xref_overrides.py @@ -37,7 +37,6 @@ if TYPE_CHECKING: from collections.abc import Mapping from types import ModuleType - from typing import Any from docutils import nodes from sphinx.addnodes import pending_xref @@ -60,7 +59,7 @@ } -def _normalize(mapping: Mapping[Any, str | _Key]) -> dict[_Key, _Key]: +def _normalize[K: str | _Key](mapping: Mapping[K, str | _Key]) -> dict[_Key, _Key]: """Make keys and values ``(role, name)`` tuples, with role :data:`None` for any role.""" return { (k if isinstance(k, tuple) else (None, k)): (v if isinstance(v, tuple) else (None, v)) @@ -88,7 +87,7 @@ def _exec_guarded_imports(module: ModuleType) -> None: _logger.debug(f"Failed to execute guarded import {ast.unparse(stmt)!r} in {module.__name__}") -def _resolve_guarded_imports(app: Sphinx, obj: Any, bound_method: bool) -> None: +def _resolve_guarded_imports(app: Sphinx, obj: object, bound_method: bool) -> None: if isinstance(module_name := getattr(obj, "__module__", None), str) and module_name in sys.modules: _exec_guarded_imports(sys.modules[module_name]) From 360a03c0c538979ebc971e30dec8e489f329351a Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Tue, 20 Oct 2026 11:34:53 +0200 Subject: [PATCH 2/3] refactor --- pyproject.toml | 2 + scripts/check_changelog.py | 169 +++++++++++++++++++++------------- tests/test_check_changelog.py | 122 ++++++++++++++++++++++++ 3 files changed, 231 insertions(+), 62 deletions(-) create mode 100644 tests/test_check_changelog.py diff --git a/pyproject.toml b/pyproject.toml index 3cf9f01..8e36d06 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,7 @@ test = [ "defusedxml", # for sphinx.testing’s HTML assertions "pytest", "pytest-cov", # For VS Code’s coverage functionality + "wenmode", # for scripts/check_changelog.py ] doc = [ "ipykernel", @@ -174,6 +175,7 @@ filterwarnings = [ "ignore:The mapping interface for autodoc options:sphinx.deprecation.RemovedInSphinx11Warning", "ignore::sphinx.deprecation.RemovedInSphinx11Warning:myst_parser", ] +pythonpath = [ "scripts" ] strict = true testpaths = [ "tests", "src" ] diff --git a/scripts/check_changelog.py b/scripts/check_changelog.py index 2492a57..204602d 100644 --- a/scripts/check_changelog.py +++ b/scripts/check_changelog.py @@ -8,7 +8,9 @@ The entry is a list item under `## [Unreleased]` › `###
` that ends with the PR number, e.g. `- Some fix (#67)`. -PR titles with types that don’t map to a section (`ci:`, `docs:`, …) pass unchecked. +PR titles with types that don’t map to a section (`ci:`, `docs:`, …) need no entry, +but every PR needs the changelog to keep its `## [Unreleased]` section, +and every `##` heading to be a link with a definition, e.g. `[0.1.0]: https://…`. Usage ----- @@ -18,82 +20,125 @@ from __future__ import annotations import re -import sys from argparse import ArgumentParser +from dataclasses import dataclass, field from pathlib import Path from typing import TYPE_CHECKING from wenmode import Wenmode -from wenmode.nodes import Heading, List, ListItem, Literal, Paragraph, Text +from wenmode.nodes import Heading, Link, List, ListItem, Literal, Paragraph, Parent, Text if TYPE_CHECKING: - from collections.abc import Generator + from wenmode.nodes import Node SECTIONS = {"feat": "Added", "fix": "Fixed", "perf": "Changed"} - - -def find_entries(changelog: str, pr: int) -> Generator[tuple[int, str | None, str | None]]: - """Find list items ending in `(#)` as `(line, h2, h3)`.""" - suffix = f"(#{pr})" - h2: str | None = None - h3: str | None = None - for node in Wenmode(positions=True).parse(changelog).children: - match node: - case Heading(depth=2 | 3 as depth, children=children): - text = "".join(c.value for c in children if isinstance(c, Literal)) - h2, h3 = (text, None) if depth == 2 else (h2, text) - case List(children=items): - for item in items: - match item: - case ListItem(children=[Paragraph(children=[*rest, Text(value=value)]), *_]): - # an item consisting only of the PR number is a placeholder, not an entry - if value.endswith(suffix) and (rest or value != suffix): - assert item.position is not None - yield (changelog.count("\n", 0, item.position.start) + 1, h2, h3) - - -def check(changelog: str, pr: int, title: str) -> tuple[int | None, str] | None: - """Return `(line, message)` describing the problem, or `None` if the entry is fine.""" - if not (m := re.match(r"(\w+)(?:\([^)]*\))?!?:", title)) or not (want := SECTIONS.get(m[1])): - return None - entries = list(find_entries(changelog, pr)) - if any(h2 == "[Unreleased]" and h3 == want for _, h2, h3 in entries): - return None - if not entries: - return None, f"No list item ending in “(#{pr})”; “{title}” needs one under “## [Unreleased]” › “### {want}”" - line, h2, h3 = entries[0] - return ( - line, - f"Entry for #{pr} is under “## {h2}” › “### {h3}”, but “{title}” needs “## [Unreleased]” › “### {want}”", - ) - - -def main() -> int: - """Return 0 if the entry is in the right section, else print a GitHub annotation and return 1.""" +UNRELEASED = "Unreleased" + + +class ChangelogError(SystemExit): + """A problem with the changelog, exiting with a GitHub error annotation when uncaught.""" + + def __init__(self, msg: str, *, file: Path, line: int | None = None) -> None: + self.msg = msg + self.line = line + loc = f"file={file}{f',line={line}' if line else ''}" + super().__init__(f"::error {loc}::{msg}") + + +@dataclass(frozen=True) +class Entry: + """A changelog list item.""" + + line: int + pr: int | None + + +@dataclass +class Changelog: + """A parsed changelog: version › section › entries.""" + + path: Path + versions: dict[str, dict[str, list[Entry]]] = field(default_factory=dict) + + @classmethod + def parse(cls, path: Path) -> Changelog: + """Parse the changelog, raising :class:`ChangelogError` if it is malformed.""" + text = path.read_text() + changelog = cls(path) + sections: dict[str, list[Entry]] | None = None + entries: list[Entry] | None = None + for node in Wenmode(positions=True).parse(text).children: + match node: + case Heading(depth=2, children=[Link() as link]): + sections = changelog.versions.setdefault(_text(link), {}) + entries = None + case Heading(depth=2): + msg = f"“## {_text(node)}” isn’t a link; add a “[]: ” definition" + raise ChangelogError(msg, file=path, line=_line(text, node)) + case Heading(depth=3) if sections is not None: + entries = sections.setdefault(_text(node), []) + case List(children=items) if entries is not None: + entries.extend(_parse_entry(text, path, item) for item in items) + if UNRELEASED not in changelog.versions: + raise ChangelogError("No “## [Unreleased]” section", file=path) + return changelog + + def check(self, pr: int, title: str) -> None: + """Raise :class:`ChangelogError` unless the entry for `pr` is in the section `title` implies.""" + if not (m := re.match(r"(\w+)(?:\([^)]*\))?!?:", title)) or not (want := SECTIONS.get(m[1])): + return + found = [ + (entry, version, section) + for version, sections in self.versions.items() + for section, entries in sections.items() + for entry in entries + if entry.pr == pr + ] + if any(version == UNRELEASED and section == want for _, version, section in found): + return + if not found: + msg = f"No list item ending in “(#{pr})”; “{title}” needs one under “## [Unreleased]” › “### {want}”" + raise ChangelogError(msg, file=self.path) + entry, version, section = found[0] + msg = f"Entry for #{pr} is under “## [{version}]” › “### {section}”, but “{title}” needs “## [Unreleased]” › “### {want}”" + raise ChangelogError(msg, file=self.path, line=entry.line) + + +def _text(node: Node) -> str: + match node: + case Literal(value=value): + return value + case Parent(children=children): + return "".join(map(_text, children)) + return "" + + +def _line(text: str, node: Node) -> int: + assert node.position is not None + return text.count("\n", 0, node.position.start) + 1 + + +def _parse_entry(text: str, path: Path, item: Node) -> Entry: + line = _line(text, item) + match item: + case ListItem(children=[Paragraph(children=[*rest, Text(value=value)]), *_]): + if m := re.search(r"\(#(\d+)\)$", value): + if not rest and value == m[0]: + raise ChangelogError(f"Entry “{value}” has no description", file=path, line=line) + return Entry(line, int(m[1])) + return Entry(line, None) + + +def main() -> None: + """Exit with a GitHub error annotation if the changelog is malformed or the entry is missing or misplaced.""" parser = ArgumentParser(description=__doc__) parser.add_argument("--pr", type=int, required=True) parser.add_argument("--title", required=True) parser.add_argument("--changelog", type=Path, default=Path("CHANGELOG.md")) args = parser.parse_args() - _self_check() - if (problem := check(args.changelog.read_text(), args.pr, args.title)) is None: - return 0 - line, msg = problem - loc = f"file={args.changelog}{f',line={line}' if line else ''}" - print(f"::error {loc}::{msg}") - return 1 - - -def _self_check() -> None: - cl = "## [Unreleased]\n\n### Fixed\n\n- A `fix` (#1)\n- (#2)\n\n## [0.1.0]\n\n### Added\n\n- Old (#3)\n" - assert check(cl, 1, "fix(x)!: y") is None - assert check(cl, 1, "ci: y") is None - assert check(cl, 1, "feat: y") == (5, "Entry for #1 is under “## [Unreleased]” › “### Fixed”, but “feat: y” needs “## [Unreleased]” › “### Added”") # fmt: skip - assert check(cl, 2, "fix: y") is not None # placeholder - assert (r := check(cl, 3, "feat: y")) is not None and r[0] == 12 # wrong release - assert check(cl, 4, "fix: y") is not None + Changelog.parse(args.changelog).check(args.pr, args.title) if __name__ == "__main__": - sys.exit(main()) + main() diff --git a/tests/test_check_changelog.py b/tests/test_check_changelog.py new file mode 100644 index 0000000..a932d62 --- /dev/null +++ b/tests/test_check_changelog.py @@ -0,0 +1,122 @@ +# SPDX-License-Identifier: MPL-2.0 +"""The PR changelog check in ``scripts/check_changelog.py``.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from check_changelog import Changelog, ChangelogError, Entry + +if TYPE_CHECKING: + from pathlib import Path + +CHANGELOG = """\ +## [Unreleased] + +### Fixed + +- A `fix` (#1) +- No PR number + +## [0.1.0] + +### Added + +- Old (#3) + +[Unreleased]: https://github.com/scverse/scverse-doc/compare/v0.1.0...main +[0.1.0]: https://github.com/scverse/scverse-doc/releases/tag/v0.1.0 +""" + + +def parse(tmp_path: Path, text: str = CHANGELOG) -> Changelog: + path = tmp_path / "CHANGELOG.md" + path.write_text(text) + return Changelog.parse(path) + + +def test_parse(tmp_path: Path) -> None: + assert parse(tmp_path).versions == { + "Unreleased": {"Fixed": [Entry(5, 1), Entry(6, None)]}, + "0.1.0": {"Added": [Entry(12, 3)]}, + } + + +@pytest.mark.parametrize( + ("text", "line", "msg"), + [ + pytest.param( + CHANGELOG.replace("[Unreleased]", "[0.2.0]"), + None, + "No “## [Unreleased]” section", + id="no-unreleased", + ), + pytest.param( + CHANGELOG.replace("[0.1.0]: ", "[0.1.1]: "), + 8, + "“## [0.1.0]” isn’t a link; add a “[]: ” definition", + id="heading-without-definition", + ), + pytest.param( + CHANGELOG.replace("No PR number", "(#2)"), + 6, + "Entry “(#2)” has no description", + id="placeholder", + ), + ], +) +def test_parse_error(tmp_path: Path, text: str, line: int | None, msg: str) -> None: + with pytest.raises(ChangelogError) as exc_info: + parse(tmp_path, text) + assert (exc_info.value.line, exc_info.value.msg) == (line, msg) + + +@pytest.mark.parametrize( + ("pr", "title"), + [ + pytest.param(1, "fix(x)!: y", id="scope-and-breaking"), + pytest.param(1, "ci: y", id="unchecked-type"), + pytest.param(4, "docs: y", id="unchecked-type-no-entry"), + ], +) +def test_check_ok(tmp_path: Path, pr: int, title: str) -> None: + parse(tmp_path).check(pr, title) + + +@pytest.mark.parametrize( + ("pr", "title", "line", "msg"), + [ + pytest.param( + 1, + "feat: y", + 5, + "Entry for #1 is under “## [Unreleased]” › “### Fixed”, but “feat: y” needs “## [Unreleased]” › “### Added”", + id="wrong-section", + ), + pytest.param( + 3, + "feat: y", + 12, + "Entry for #3 is under “## [0.1.0]” › “### Added”, but “feat: y” needs “## [Unreleased]” › “### Added”", + id="wrong-release", + ), + pytest.param( + 4, + "fix: y", + None, + "No list item ending in “(#4)”; “fix: y” needs one under “## [Unreleased]” › “### Fixed”", + id="missing", + ), + ], +) +def test_check_error(tmp_path: Path, pr: int, title: str, line: int | None, msg: str) -> None: + with pytest.raises(ChangelogError) as exc_info: + parse(tmp_path).check(pr, title) + assert (exc_info.value.line, exc_info.value.msg) == (line, msg) + + +def test_error_exits_with_annotation(tmp_path: Path) -> None: + with pytest.raises(SystemExit) as exc_info: + parse(tmp_path).check(4, "fix: y") + assert str(exc_info.value.code).startswith(f"::error file={tmp_path / 'CHANGELOG.md'}::No list item") From cdf2a3736b950c17521feede60063293c67fe905 Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Tue, 20 Oct 2026 11:37:52 +0200 Subject: [PATCH 3/3] fix --- src/scverse_doc/source.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/scverse_doc/source.py b/src/scverse_doc/source.py index 2cecdb8..d3fe168 100644 --- a/src/scverse_doc/source.py +++ b/src/scverse_doc/source.py @@ -201,8 +201,7 @@ def _linkcode_url(source: _Source, module: str, fullname: str) -> str | None: obj = import_module(module) for part in filter(None, fullname.split(".")): obj = getattr(obj, part) - assert callable(obj) - obj = inspect.unwrap(obj) + obj = inspect.unwrap(obj) if callable(obj) else obj lines, start = inspect.getsourcelines(obj) file = Path(inspect.getsourcefile(obj) or "") except (ImportError, AttributeError, TypeError, OSError):