Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/workflows/pr.yaml
Original file line number Diff line number Diff line change
@@ -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 }}
10 changes: 7 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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]

Expand Down
6 changes: 5 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -62,7 +63,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"
Expand Down Expand Up @@ -159,6 +160,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 = [
Expand All @@ -172,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" ]

Expand Down
144 changes: 144 additions & 0 deletions scripts/check_changelog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
#!/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]` › `### <Section>` that ends with the PR number,
e.g. `- Some fix (#67)`.
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
-----
uv run scripts/check_changelog.py --pr 67 --title 'fix: make logo fill sidebar'
"""

from __future__ import annotations

import re
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, Link, List, ListItem, Literal, Paragraph, Parent, Text

if TYPE_CHECKING:
from wenmode.nodes import Node

SECTIONS = {"feat": "Added", "fix": "Fixed", "perf": "Changed"}
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 “[<version>]: <url>” 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()

Changelog.parse(args.changelog).check(args.pr, args.title)


if __name__ == "__main__":
main()
4 changes: 1 addition & 3 deletions src/scverse_doc/autodoc/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/scverse_doc/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down
17 changes: 14 additions & 3 deletions src/scverse_doc/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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


Expand Down
12 changes: 6 additions & 6 deletions src/scverse_doc/source.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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("/")
Expand Down Expand Up @@ -198,10 +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)
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):
Expand Down
6 changes: 3 additions & 3 deletions src/scverse_doc/theme/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
Loading
Loading