diff --git a/README.md b/README.md index 0d50385..3397534 100644 --- a/README.md +++ b/README.md @@ -13,17 +13,17 @@ Install it, and a package's `conf.py` becomes: ```python extensions = ["scverse_doc"] -html_theme_options = {"repo": "scverse/pertpy"} +source_repository = "https://github.com/scverse/pertpy" ``` -That gets you the scverse brand and dark mode, the shared navbar, footer, and announcement banner, a "scverse packages" dropdown generated from the package registry, cross-links to every core package, and the standard extension stack. +That gets you the scverse brand and dark mode, the shared navbar, footer, and announcement banner, a "scverse packages" dropdown generated from the package registry, cross-links to every core package, the repository links ("edit this page" and the navbar icon), and the standard extension stack. Every piece also works on its own. For the theme without the extension stack or the shared defaults, select it and leave `extensions` alone – it is a registered Sphinx theme, so installing the package is enough: ```python html_theme = "scverse" -html_theme_options = {"repo": "scverse/pertpy"} +source_repository = "https://github.com/scverse/pertpy" ``` ## Getting started diff --git a/docs/api.md b/docs/api.md index efe64a0..f179a4c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -11,6 +11,7 @@ api/config.md api/theme.md +api/source.md api/registry.md ``` @@ -30,6 +31,11 @@ Registers the theme and its {ref}`theme options `. Selecting it with `html_theme = "scverse"` is enough on its own – it is a registered Sphinx theme, so it needs no `extensions` entry. +## {doc}`api/source` + +Repository links – the navbar icon, “edit this page”, and `[source]` – +from the {ref}`source_* config values `, for whichever theme is selected. + ## {doc}`api/registry` ```{eval-rst} diff --git a/docs/api/source.md b/docs/api/source.md new file mode 100644 index 0000000..8bb4436 --- /dev/null +++ b/docs/api/source.md @@ -0,0 +1,76 @@ +# `scverse_doc.source` + +```{eval-rst} +.. automodule:: scverse_doc.source +``` + +(source-options)= + +## Configuration + +```{confval} source_repository +:type: str +:default: `""` + +The repository URL, e.g. `"https://gitlab.com/owner/name"`. +Empty means no repository links at all. +``` + +```{confval} source_branch +:type: str +:default: `$READTHEDOCS_GIT_IDENTIFIER`, else `"main"` + +The ref the links point at. +Read the Docs pull request builds fall back to the default, since they identify by PR number. +``` + +```{confval} source_directory +:type: str +:default: `"docs"` + +Where the documentation sources live in the repository. +``` + +```{confval} source_code_directory +:type: str +:default: `"src"` + +Where the importable code lives in the repository, for the `[source]` links. +Set it to `""` for a flat layout. +``` + +```{confval} source_provider +:type: str +:default: inferred from the host + +Which forge’s URL layout the repository follows: +`"github"`, `"gitlab"` or `"bitbucket"`. +Inferring it works for the hosted instances and for self-hosted ones whose host +name contains the forge’s (`gitlab.example.org`); name it for anything else. +An unknown forge still gets the navbar icon, just no per-page links. +``` + +## What each theme gets + +| Theme | Reads | +| --- | --- | +| `pydata-sphinx-theme`, and so {doc}`ours ` | `html_context`’s `{provider}_user`/`_repo`/`_version`/`_url` and `doc_path`, plus `use_edit_page_button` | +| `sphinx_rtd_theme` | the same, plus `display_{provider}`, `{provider}_host` and `conf_py_path` | +| `furo`, and anything else on `sphinx-basic-ng` | the `source_repository`/`source_branch`/`source_directory` theme options, plus `display_{provider}` for its footer icon – which it shows on Read the Docs only | +| `sphinx-book-theme` | the `repository_url`/`repository_branch`/`repository_provider`/`path_to_docs` theme options, plus `use_repository_button`, `use_source_button` and `use_issues_button` | + +Only the options a theme declares are written; a theme that declares none of them – +`alabaster`, say – is left alone. Anything `conf.py` set itself wins. + +## `[source]` links + +Listing {mod}`sphinx.ext.linkcode` is enough; the resolver is filled in: + +```python +extensions = ["scverse_doc.source", "sphinx.ext.linkcode"] + +source_repository = "https://github.com/scverse/pertpy" +# no need to define `linkcode_resolve` +``` + +A `linkcode_resolve` of your own still wins. diff --git a/docs/api/theme.md b/docs/api/theme.md index 7368ee3..b42f395 100644 --- a/docs/api/theme.md +++ b/docs/api/theme.md @@ -10,6 +10,7 @@ `html_theme_options` keys this theme adds. Everything else there is [pydata-sphinx-theme’s](https://pydata-sphinx-theme.readthedocs.io/en/stable/user_guide/layout.html). +The repository links have their own {ref}`config values `. ```{confval} package :type: str @@ -18,28 +19,6 @@ The registry name to look the accent and the dropdown’s current entry up under. ``` -```{confval} repo -:type: str -:default: `""` - -`owner/name` on GitHub. Adds the navbar icon and the “edit this page” button. -``` - -```{confval} branch -:type: str -:default: `$READTHEDOCS_GIT_IDENTIFIER`, else `"main"` - -The ref {confval}`repo`’s edit links point at. -Read the Docs pull request builds fall back to the default, since they identify by PR number. -``` - -```{confval} doc_path -:type: str -:default: `"docs/"` - -Where the documentation sources live in {confval}`repo`. -``` - ```{confval} accent :type: str :default: the registry accent, else the scverse primary diff --git a/docs/conf.py b/docs/conf.py index 5b1c919..6237754 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,8 +1,9 @@ """Sphinx docs configuration.""" project = "scverse-doc" -extensions = ["scverse_doc", "sphinxcontrib.bibtex"] -html_theme_options = {"repo": "scverse/scverse-doc", "announcement": ""} +extensions = ["scverse_doc", "sphinxcontrib.bibtex", "sphinx.ext.linkcode"] +html_theme_options = {"announcement": ""} +source_repository = "https://github.com/scverse/scverse-doc" bibtex_bibfiles = ["references.bib"] diff --git a/pyproject.toml b/pyproject.toml index 5edc027..3c20b20 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,9 +48,11 @@ dev = [ ] test = [ "coverage>=7.10", - "defusedxml", # for sphinx.testing’s HTML assertions + "defusedxml", # for sphinx.testing’s HTML assertions + "furo", # to check `scverse_doc.source` against a non-pydata theme "pytest", - "pytest-cov", # For VS Code’s coverage functionality + "pytest-cov", # For VS Code’s coverage functionality + "sphinx-book-theme", ] doc = [ "ipykernel", @@ -138,6 +140,10 @@ addopts = [ "--import-mode=importlib", # allow using test files with same name "--doctest-modules", ] +filterwarnings = [ + "error", + "ignore::PendingDeprecationWarning:sphinx_book_theme", +] strict = true testpaths = [ "tests", "src" ] diff --git a/scripts/sync_brand_tokens.py b/scripts/sync_brand_tokens.py index 59c4a72..a2c4a82 100755 --- a/scripts/sync_brand_tokens.py +++ b/scripts/sync_brand_tokens.py @@ -87,7 +87,7 @@ def render_region(scss: str, indent: str) -> str: msg = f"SCSS variables vanished from the website: {', '.join('$' + m for m in missing)}" raise KeyError(msg) - values = {**LITERALS, **{prop: variables[scss_name] for scss_name, prop in TOKEN_MAP.items()}} + values = LITERALS | {prop: variables[scss_name] for scss_name, prop in TOKEN_MAP.items()} lines = [BEGIN, *(f"{prop}-light: {value};" for prop, value in values.items()), END] return "\n".join(indent + line for line in lines) diff --git a/src/scverse_doc/__init__.py b/src/scverse_doc/__init__.py index f4e5f57..2d941bc 100644 --- a/src/scverse_doc/__init__.py +++ b/src/scverse_doc/__init__.py @@ -6,7 +6,7 @@ extensions = ["scverse_doc"] - html_theme_options = {"repo": "scverse/pertpy"} + source_repository = "https://github.com/scverse/pertpy" This extension sets up the subextensions, each of which also works on its own: @@ -15,6 +15,8 @@ The extension stack and the shared defaults. :mod:`scverse_doc.registry` The package registry, usable as an :func:`~scverse_doc.registry.intersphinx` mapping. +:mod:`scverse_doc.source` + The repository links: the navbar icon, “edit this page”, and ``[source]``. :mod:`scverse_doc.theme` The theme, its chrome, and the per-package accent. @@ -27,14 +29,14 @@ from sphinx.util.typing import ExtensionMetadata -from . import config, registry, theme +from . import config, registry, source, theme from .config import _is_set_by_user if TYPE_CHECKING: from sphinx.application import Sphinx from sphinx.config import Config -__all__ = ["config", "registry", "theme", "setup"] +__all__ = ["config", "registry", "source", "theme", "setup"] def _default_theme(app: Sphinx, config: Config) -> None: diff --git a/src/scverse_doc/registry.py b/src/scverse_doc/registry.py index bf88789..db57251 100644 --- a/src/scverse_doc/registry.py +++ b/src/scverse_doc/registry.py @@ -197,7 +197,7 @@ def intersphinx(*extra: str, external: bool = True, core: bool = True) -> ChainM >>> intersphinx_mapping["scanpy"] # doctest: +ELLIPSIS ('https://scanpy.scverse.org/...', None) - >>> intersphinx_mapping = {**intersphinx(), "my_package": ("...", None)} + >>> intersphinx_mapping = intersphinx() | {"my_package": ("...", None)} >>> intersphinx_mapping["my_package"] ('...', None) """ diff --git a/src/scverse_doc/source.py b/src/scverse_doc/source.py new file mode 100644 index 0000000..185a527 --- /dev/null +++ b/src/scverse_doc/source.py @@ -0,0 +1,243 @@ +"""Links into the source repository: the repository icon, “edit this page”, and ``[source]``. + +Declare the repository with :confval:`source_repository`. +Whatever the selected theme understands is filled in: `pydata-sphinx-theme`’s +`html_context` entries, `furo`’s and `sphinx-book-theme`’s theme options, and the navbar icon. + +If `conf.py` lists :mod:`sphinx.ext.linkcode`, this also resolves that extension’s +``[source]`` links. + +Hosts other than GitHub work as long as their URLs are laid out like GitHub’s, +GitLab’s or Bitbucket’s; :confval:`source_provider` names the layout for a self-hosted one. +""" + +from __future__ import annotations + +import inspect +import os +from dataclasses import dataclass +from enum import Enum +from importlib import import_module +from pathlib import Path, PurePosixPath +from typing import TYPE_CHECKING, Any, NamedTuple, Self +from urllib.parse import urlsplit + +from sphinx.errors import ConfigError +from sphinx.util.typing import ExtensionMetadata + +if TYPE_CHECKING: + from sphinx.application import Sphinx + from sphinx.config import Config + +__all__ = ["setup"] + + +class _ProviderData(NamedTuple): + """How one forge lays out its URLs, relative to the repository URL.""" + + label: str + """What a reader sees, e.g. on the icon link.""" + + icon: str + view: str + lines: str + + +#: The forge layouts we can build links for, keyed by the name that appears in their hosts. +#: A self-hosted instance picks one with :confval:`source_provider`. +class _Provider(_ProviderData, Enum): + github = ("GitHub", "fa-brands fa-github", "blob/{ref}/{path}", "#L{start}-L{end}") + gitlab = ("GitLab", "fa-brands fa-gitlab", "-/blob/{ref}/{path}", "#L{start}-{end}") + bitbucket = ("Bitbucket", "fa-brands fa-bitbucket", "src/{ref}/{path}", "#lines-{start}:{end}") + _unknown = ("Source", "fa-solid fa-code-branch", "", "") + + def __bool__(self) -> bool: + return self is not self._unknown + + @classmethod + def parse(cls, name: str, host: str) -> _Provider: + """The forge named by :confval:`source_provider`, else the one `host` is named after.""" + if not name: + return next((member for member in cls if member and member.name in host), cls._unknown) + if (member := cls.__members__.get(name)) is None or not member: + known = ", ".join(repr(member.name) for member in cls if member) + msg = f"source_provider is {name!r}, but the layouts we know are {known}." + raise ConfigError(msg) + return member + + +@dataclass(frozen=True) +class _Source: + """Where a package’s source lives, as derived from the ``source_*`` config values.""" + + url: str + """The repository URL, without a trailing slash.""" + + ref: str + """The branch or tag the links point at.""" + + directory: str + """Where the documentation sources live in the repository.""" + + code: str + """Where the importable code lives in the repository – ``"src"`` in a src layout.""" + + provider: _Provider + """How this repository’s forge lays out its URLs.""" + + @classmethod + def from_config(cls, config: Config) -> Self | None: + """Describe the repository, or :data:`None` if `conf.py` declared none.""" + if not (url := str(config.source_repository).rstrip("/")): + return None + host = urlsplit(url).netloc + return cls( + url=url, + ref=_ref(config), + directory=str(config.source_directory).strip("/"), + code=str(config.source_code_directory).strip("/"), + provider=_Provider.parse(config.source_provider, host), + ) + + @property + def icon_link(self) -> dict[str, str]: + """The navbar icon link pointing at the repository.""" + return {"name": self.provider.label, "url": self.url, "icon": self.provider.icon} + + def view(self, path: PurePosixPath | str, lines: tuple[int, int] | None = None) -> str | None: + """Link to a file in the repository, optionally highlighting a line range. + + :data:`None` if the forge is unknown. + """ + if not self.provider.view: + return None + fragment = self.provider.lines.format(start=lines[0], end=lines[1]) if lines else "" + return f"{self.url}/{self.provider.view.format(ref=self.ref, path=path)}{fragment}" + + +def _ref(config: Config) -> str: + """The ref links point at: the config value, else what Read the Docs is building, else ``main``.""" + if branch := config.source_branch: + return str(branch) + # On pull request builds the identifier is the PR number, not a ref. + if os.environ.get("READTHEDOCS_VERSION_TYPE") != "external" and ( + ref := os.environ.get("READTHEDOCS_GIT_IDENTIFIER") + ): + return ref + return "main" + + +def _theme_options(source: _Source) -> dict[str, Any]: + """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] = { + # sphinx-basic-ng, and so furo + "source_repository": source.url, + "source_branch": source.ref, + "source_directory": source.directory, + # sphinx-book-theme + "repository_url": source.url, + "repository_branch": source.ref, + "repository_provider": source.provider.name, + "path_to_docs": source.directory, + # pydata-sphinx-theme + "use_edit_page_button": True, + # sphinx-book-theme’s article header buttons + "use_repository_button": True, + "use_source_button": True, + } + if source.provider in {_Provider.github, _Provider.gitlab}: # sphinx-book-theme warns for the others + options["use_issues_button"] = True + return options + + +def _html_context(source: _Source) -> dict[str, Any]: + """The `html_context` entries `pydata-sphinx-theme`, `sphinx_rtd_theme` and `furo` read.""" + parts = urlsplit(source.url) + owner, _, name = parts.path.strip("/").rpartition("/") + return { + # `sphinx_rtd_theme` picks the forge by this, `furo` gates its footer icon on it. + f"display_{source.provider.name}": True, + f"{source.provider.name}_url": f"{parts.scheme}://{parts.netloc}", + f"{source.provider.name}_host": parts.netloc, + f"{source.provider.name}_user": owner, + f"{source.provider.name}_repo": name, + f"{source.provider.name}_version": source.ref, + "doc_path": source.directory, + # What `sphinx_rtd_theme` and `furo` append straight after the ref, slashes included. + "conf_py_path": f"/{source.directory}/" if source.directory else "/", + } + + +def _configure_theme(app: Sphinx) -> None: + """Fill in whatever the selected theme understands. + + On ``builder-inited``, since which options a theme declares is only known + once the builder created it, and writing an undeclared one warns. + `pydata-sphinx-theme` reads these from the same event, but connects later. + """ + config = app.config + if (source := _Source.from_config(config)) is None or (theme := getattr(app.builder, "theme", None)) is None: + return + declared = theme.get_options() + options = config.html_theme_options + for name, value in _theme_options(source).items(): + if name in declared: + options.setdefault(name, value) + if "icon_links" in declared: + # Prepended, so this works whichever order the theme’s own hook runs in. + options["icon_links"] = [source.icon_link, *options.get("icon_links", [])] + if source.provider: + config.html_context = _html_context(source) | config.html_context + + +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) + for part in filter(None, fullname.split(".")): + obj = getattr(obj, part) + obj = inspect.unwrap(obj) + lines, start = inspect.getsourcelines(obj) + file = Path(inspect.getsourcefile(obj) or "") + except (ImportError, AttributeError, TypeError, OSError): + return None + # A non-editable install puts the file outside the checkout, + # so the repository-relative path comes from the defining module’s depth. + parts = str(getattr(obj, "__module__", module) or module).split(".") + offset = -1 if file.name == "__init__.py" else 0 + path = PurePosixPath(source.code, *file.parts[offset - len(parts) :]) + return source.view(path, (start, start + len(lines) - 1)) + + +def _configure_linkcode(app: Sphinx) -> None: + """Resolve `sphinx.ext.linkcode`’s links, if `conf.py` asked for it but defined no resolver.""" + config = app.config + if "sphinx.ext.linkcode" not in app.extensions or config.linkcode_resolve is not None: + return + if (source := _Source.from_config(config)) is None: + return + + def linkcode_resolve(domain: str, info: dict[str, str]) -> str | None: + if domain != "py" or not info["module"]: + return None + return _linkcode_url(source, info["module"], info["fullname"]) + + config.linkcode_resolve = linkcode_resolve + + +def setup(app: Sphinx) -> ExtensionMetadata: + """Register the ``source_*`` config values and wire them into the theme and linkcode.""" + app.add_config_value("source_repository", "", "env", types=frozenset({str})) + app.add_config_value("source_branch", "", "env", types=frozenset({str})) + app.add_config_value("source_directory", "docs", "env", types=frozenset({str})) + app.add_config_value("source_code_directory", "src", "env", types=frozenset({str})) + app.add_config_value("source_provider", "", "env", types=frozenset({str})) + + app.connect("builder-inited", _configure_linkcode) + app.connect("builder-inited", _configure_theme) + + return ExtensionMetadata(parallel_read_safe=True) diff --git a/src/scverse_doc/theme/__init__.py b/src/scverse_doc/theme/__init__.py index 966c91d..be631cb 100644 --- a/src/scverse_doc/theme/__init__.py +++ b/src/scverse_doc/theme/__init__.py @@ -3,13 +3,13 @@ :mod:`scverse_doc` selects this theme; on its own, set ``html_theme = "scverse"``. Declare the package with the :ref:`theme options `; `pydata-sphinx-theme`’s own options work as well. +This theme sets up :mod:`scverse_doc.source`, which adds the repository links. The accent colour and the “scverse packages” dropdown come from the :mod:`registry `, without any configuration. """ from __future__ import annotations -import os from pathlib import Path from typing import TYPE_CHECKING, Any @@ -56,35 +56,12 @@ def _accent(config: Config) -> str: return DEFAULT_ACCENT -def _branch(options: dict[str, Any]) -> str: - """The ref edit links point at: the option, else what Read the Docs is building, else ``main``.""" - if branch := options.get("branch"): - return str(branch) - # On pull request builds the identifier is the PR number, not a ref. - if os.environ.get("READTHEDOCS_VERSION_TYPE") != "external" and ( - ref := os.environ.get("READTHEDOCS_GIT_IDENTIFIER") - ): - return ref - return "main" - - -def _expand_repo(config: Config) -> None: - """Turn the single ``repo`` theme option into the GitHub chrome pydata expects.""" +def _add_chrome(config: Config) -> None: + """Add the brand icon links and the colour mode, alongside whatever :mod:`scverse_doc.source` set.""" options = config.html_theme_options - icon_links = list(options.get("icon_links", [])) - if repo := str(options.get("repo", "")): - owner, _, name = repo.partition("/") - icon_links.insert(0, {"name": "GitHub", "url": f"https://github.com/{repo}", "icon": "fa-brands fa-github"}) - options.setdefault("use_edit_page_button", True) - config.html_context = { - "github_user": owner, - "github_repo": name, - "github_version": _branch(options), - "doc_path": options.get("doc_path", "docs/"), - "default_mode": "auto", - **config.html_context, - } - options["icon_links"] = [*icon_links, *_ICON_LINKS] + # Appended, so this works whichever order `scverse_doc.source`’s hook runs in. + options["icon_links"] = [*options.get("icon_links", []), *_ICON_LINKS] + config.html_context = {"default_mode": "auto", **config.html_context} def _configure(app: Sphinx) -> None: @@ -101,7 +78,7 @@ def _configure(app: Sphinx) -> None: if config.html_theme != "scverse": return - _expand_repo(config) + _add_chrome(config) accent = _accent(config) static_dir = _build_cache(app) / "static" @@ -138,6 +115,7 @@ def setup(app: Sphinx) -> ExtensionMetadata: """Register the theme, its templates, and the build hooks.""" app.add_html_theme("scverse", str(_THEME_PATH)) app.config.templates_path = [*app.config.templates_path, str(_THEME_PATH / "components")] + app.setup_extension("scverse_doc.source") app.connect("builder-inited", _configure) app.connect("html-page-context", _add_ecosystem_context) diff --git a/src/scverse_doc/theme/scverse/theme.conf b/src/scverse_doc/theme/scverse/theme.conf index d970eb0..09bb675 100644 --- a/src/scverse_doc/theme/scverse/theme.conf +++ b/src/scverse_doc/theme/scverse/theme.conf @@ -4,9 +4,6 @@ stylesheet = styles/scverse.css pygments_style = tango [options] -repo = -branch = -doc_path = docs/ package = accent = show_ecosystem_dropdown = True diff --git a/tests/roots/entry-point/conf.py b/tests/roots/entry-point/conf.py new file mode 100644 index 0000000..355fbfa --- /dev/null +++ b/tests/roots/entry-point/conf.py @@ -0,0 +1,9 @@ +project = "pertpy" +# ``scverse_doc`` is not listed: Sphinx must load the theme from its entry point, +# and the theme must bring ``scverse_doc.source`` with it – late enough that +# ``config-inited`` has already fired. +html_theme = "scverse" +extensions = ["sphinx.ext.linkcode"] +source_repository = "https://github.com/scverse/pertpy" +source_branch = "main" +html_theme_options = {"announcement": ""} diff --git a/tests/roots/theme-only/index.rst b/tests/roots/entry-point/index.rst similarity index 100% rename from tests/roots/theme-only/index.rst rename to tests/roots/entry-point/index.rst diff --git a/tests/roots/minimal/conf.py b/tests/roots/minimal/conf.py index 73fc04f..47f9beb 100644 --- a/tests/roots/minimal/conf.py +++ b/tests/roots/minimal/conf.py @@ -1,3 +1,5 @@ project = "pertpy" -extensions = ["scverse_doc"] -html_theme_options = {"repo": "scverse/pertpy", "branch": "main", "doc_path": "docs/", "announcement": ""} +extensions = ["scverse_doc", "sphinx.ext.linkcode"] +source_repository = "https://github.com/scverse/pertpy" +source_branch = "main" +html_theme_options = {"announcement": ""} diff --git a/tests/roots/theme-only/conf.py b/tests/roots/theme-only/conf.py deleted file mode 100644 index 5cf6f54..0000000 --- a/tests/roots/theme-only/conf.py +++ /dev/null @@ -1,4 +0,0 @@ -project = "pertpy" -# No ``extensions``: Sphinx must load the theme from its entry point. -html_theme = "scverse" -html_theme_options = {"repo": "scverse/pertpy", "branch": "main", "announcement": ""} diff --git a/tests/test_build.py b/tests/test_build.py index 11e2a6e..89c5127 100644 --- a/tests/test_build.py +++ b/tests/test_build.py @@ -55,6 +55,15 @@ def test_theme_renders_the_shared_chrome(minimal: tuple[Sphinx, str]) -> None: assert css in html assert "NumFOCUS" in html assert "github.com/scverse/pertpy/edit/main/docs/index.md" in html + assert 'href="https://github.com/scverse/pertpy"' in html + + +def test_linkcode_is_resolved_when_requested(minimal: tuple[Sphinx, str]) -> None: + app, _ = minimal + assert callable(app.config.linkcode_resolve) + assert app.config.linkcode_resolve("py", {"module": "scverse_doc.registry", "fullname": "Package"}).startswith( + "https://github.com/scverse/pertpy/blob/main/src/scverse_doc/registry.py#L" + ) def test_ecosystem_dropdown_is_built_from_the_registry(minimal: tuple[Sphinx, str]) -> None: @@ -92,16 +101,27 @@ def test_rebuilding_reuses_everything(tmp_path: Path) -> None: def test_theme_works_without_being_listed_as_an_extension(tmp_path: Path) -> None: """Selecting the theme loads it from the entry point, which happens after ``config-inited``.""" - app, html = build(ROOTS / "theme-only", tmp_path) + app, html = build(ROOTS / "entry-point", tmp_path) assert "scverse_doc.theme" in app.extensions assert "scverse-accent.css" in html - assert "github.com/scverse/pertpy" in html assert "scverse-ecosystem-dropdown" in html + assert "scverse_doc.source" in app.extensions + assert "github.com/scverse/pertpy/edit/main/docs/index.rst" in html + assert callable(app.config.linkcode_resolve) + + +@pytest.mark.parametrize("theme", ["furo", "sphinx_book_theme"]) +def test_source_links_reach_other_themes(tmp_path: Path, theme: str) -> None: + """The names `scverse_doc.source` fills in are the ones these themes really read.""" + _, html = build(ROOTS / "minimal", tmp_path, html_theme=theme, html_theme_options={}) + assert "github.com/scverse/pertpy/edit/main/docs/index.md" in html def test_conf_py_can_pick_another_theme(tmp_path: Path) -> None: + """A theme that declares none of the source options must not be given any.""" app, _ = build(ROOTS / "minimal", tmp_path, html_theme="alabaster") assert app.config.html_theme == "alabaster" + assert app.config.html_theme_options.keys() == {"announcement"} def test_conf_py_wins_over_defaults(tmp_path: Path) -> None: diff --git a/tests/test_source.py b/tests/test_source.py new file mode 100644 index 0000000..ef0f8a1 --- /dev/null +++ b/tests/test_source.py @@ -0,0 +1,185 @@ +"""The repository description derived from the ``source_*`` config values.""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import TYPE_CHECKING, cast + +import pytest +from sphinx.errors import ConfigError + +from scverse_doc.source import _html_context, _linkcode_url, _Provider, _Source, _theme_options + +if TYPE_CHECKING: + from collections.abc import Mapping + + from sphinx.config import Config + + +def config(**overrides: str) -> Config: + """A stand-in for the config values :func:`scverse_doc.source.setup` registers.""" + defaults = { + "source_repository": "", + "source_branch": "", + "source_directory": "docs", + "source_code_directory": "src", + "source_provider": "", + } + return cast("Config", SimpleNamespace(**defaults | overrides)) + + +@pytest.fixture(autouse=True) +def _no_rtd(monkeypatch: pytest.MonkeyPatch) -> None: + for name in ("READTHEDOCS_GIT_IDENTIFIER", "READTHEDOCS_VERSION_TYPE"): + monkeypatch.delenv(name, raising=False) + + +@pytest.mark.parametrize( + ("env", "expected"), + [ + pytest.param({}, "main", id="local"), + pytest.param({"READTHEDOCS_GIT_IDENTIFIER": "1.2.x"}, "1.2.x", id="rtd-branch"), + pytest.param({"READTHEDOCS_GIT_IDENTIFIER": "42", "READTHEDOCS_VERSION_TYPE": "external"}, "main", id="rtd-pr"), + ], +) +def test_derives_ref(monkeypatch: pytest.MonkeyPatch, env: Mapping[str, str], expected: str) -> None: + for name, value in env.items(): + monkeypatch.setenv(name, value) + source = _Source.from_config(config(source_repository="https://github.com/scverse/pertpy")) + assert source is not None + assert source.ref == expected + + +def test_branch_option_wins(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("READTHEDOCS_GIT_IDENTIFIER", "1.2.x") + source = _Source.from_config(config(source_repository="https://github.com/scverse/pertpy", source_branch="master")) + assert source is not None + assert source.ref == "master" + + +def test_no_repository() -> None: + assert _Source.from_config(config()) is None + + +@pytest.mark.parametrize( + ("repository", "url", "provider"), + [ + pytest.param( + "https://github.com/scverse/pertpy/", "https://github.com/scverse/pertpy", _Provider.github, id="github" + ), + pytest.param("https://gitlab.com/o/r", "https://gitlab.com/o/r", _Provider.gitlab, id="gitlab"), + # A self-hosted instance is recognised by its host name. + pytest.param( + "https://gitlab.example.org/o/r", "https://gitlab.example.org/o/r", _Provider.gitlab, id="self-hosted" + ), + pytest.param("https://git.example.org/o/r", "https://git.example.org/o/r", _Provider._unknown, id="unknown"), + ], +) +def test_derives_url_and_provider(repository: str, url: str, provider: _Provider) -> None: + source = _Source.from_config(config(source_repository=repository)) + assert source is not None + assert (source.url, source.provider) == (url, provider) + + +def test_provider_can_be_declared() -> None: + source = _Source.from_config(config(source_repository="https://git.example.org/o/r", source_provider="github")) + assert source is not None + assert source.provider is _Provider.github + + +@pytest.mark.parametrize("name", [pytest.param("Github", id="typo"), pytest.param("_unknown", id="placeholder")]) +def test_unusable_provider_says_what_it_knows(name: str) -> None: + with pytest.raises(ConfigError, match=r"'github', 'gitlab', 'bitbucket'"): + _Source.from_config(config(source_repository="https://git.example.org/o/r", source_provider=name)) + + +GITHUB = _Source( + url="https://github.com/scverse/pertpy", ref="main", directory="docs", code="src", provider=_Provider.github +) +UNKNOWN = _Source( + url="https://git.example.org/o/r", ref="main", directory="docs", code="src", provider=_Provider._unknown +) + + +def test_view_links_at_lines() -> None: + assert GITHUB.view("docs/index.md") == "https://github.com/scverse/pertpy/blob/main/docs/index.md" + assert GITHUB.view("a.py", (3, 7)) == "https://github.com/scverse/pertpy/blob/main/a.py#L3-L7" + + +def test_unknown_forge_links_only_to_the_repository() -> None: + assert UNKNOWN.view("docs/index.md") is None + assert UNKNOWN.icon_link["url"] == "https://git.example.org/o/r" + assert "use_edit_page_button" not in _theme_options(UNKNOWN) + + +def test_theme_options_cover_the_themes_we_know() -> None: + options = _theme_options(GITHUB) + # sphinx-book-theme’s header buttons + assert options["use_repository_button"] is True + assert options["use_source_button"] is True + assert options["use_issues_button"] is True + # furo / sphinx-basic-ng + assert options["source_repository"] == "https://github.com/scverse/pertpy" + assert (options["source_branch"], options["source_directory"]) == ("main", "docs") + # sphinx-book-theme + assert options["repository_url"] == "https://github.com/scverse/pertpy" + assert (options["repository_branch"], options["path_to_docs"]) == ("main", "docs") + assert options["repository_provider"] == "github" + # pydata-sphinx-theme + assert options["use_edit_page_button"] is True + + +def test_html_context_is_what_the_themes_build_edit_urls_from() -> None: + assert _html_context(GITHUB) == { + "display_github": True, + "github_url": "https://github.com", + "github_host": "github.com", + "github_user": "scverse", + "github_repo": "pertpy", + "github_version": "main", + "doc_path": "docs", + "conf_py_path": "/docs/", + } + + +def test_html_context_names_the_declared_forge() -> None: + """A self-hosted instance keeps its host, but the entries are the forge’s.""" + gitlab = _Source.from_config(config(source_repository="https://gitlab.example.org/group/sub/r")) + assert gitlab is not None + assert _html_context(gitlab) == { + "display_gitlab": True, + "gitlab_url": "https://gitlab.example.org", + "gitlab_host": "gitlab.example.org", + "gitlab_user": "group/sub", + "gitlab_repo": "r", + "gitlab_version": "main", + "doc_path": "docs", + "conf_py_path": "/docs/", + } + + +@pytest.mark.parametrize( + ("module", "fullname", "path"), + [ + pytest.param("scverse_doc.registry", "intersphinx", "src/scverse_doc/registry.py", id="module"), + # A package resolves to its ``__init__.py``, one level deeper than its name. + pytest.param("scverse_doc.theme", "setup", "src/scverse_doc/theme/__init__.py", id="package"), + ], +) +def test_linkcode_finds_our_own_source(module: str, fullname: str, path: str) -> None: + url = _linkcode_url(GITHUB, module, fullname) + assert url is not None + prefix, _, lines = url.partition("#") + assert prefix == f"https://github.com/scverse/pertpy/blob/main/{path}" + assert lines.startswith("L") + + +@pytest.mark.parametrize( + ("module", "fullname"), + [ + pytest.param("scverse_doc.registry", "nope", id="missing"), + pytest.param("scverse_doc.config", "DEFAULTS", id="no-source-lines"), + ], +) +def test_linkcode_gives_up_quietly(module: str, fullname: str) -> None: + assert _linkcode_url(GITHUB, module, fullname) is None diff --git a/tests/test_theme.py b/tests/test_theme.py deleted file mode 100644 index 013c0e1..0000000 --- a/tests/test_theme.py +++ /dev/null @@ -1,37 +0,0 @@ -"""The branch edit links point at.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -import pytest - -from scverse_doc.theme import _branch - -if TYPE_CHECKING: - from collections.abc import Mapping - - -@pytest.fixture(autouse=True) -def _no_rtd(monkeypatch: pytest.MonkeyPatch) -> None: - for name in ("READTHEDOCS_GIT_IDENTIFIER", "READTHEDOCS_VERSION_TYPE"): - monkeypatch.delenv(name, raising=False) - - -@pytest.mark.parametrize( - ("env", "expected"), - [ - pytest.param({}, "main", id="local"), - pytest.param({"READTHEDOCS_GIT_IDENTIFIER": "1.2.x"}, "1.2.x", id="rtd-branch"), - pytest.param({"READTHEDOCS_GIT_IDENTIFIER": "42", "READTHEDOCS_VERSION_TYPE": "external"}, "main", id="rtd-pr"), - ], -) -def test_derives_branch(monkeypatch: pytest.MonkeyPatch, env: Mapping[str, str], expected: str) -> None: - for name, value in env.items(): - monkeypatch.setenv(name, value) - assert _branch({}) == expected - - -def test_option_wins(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("READTHEDOCS_GIT_IDENTIFIER", "1.2.x") - assert _branch({"branch": "master"}) == "master"