diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 581777eb..cefaaa8a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -342,6 +342,18 @@ repos: entry: |- (? - Source: - Releases: -- [CHANGELOG.md](./CHANGELOG.md), and [RELEASE_NOTES.md](./RELEASE_NOTES.md) +- [CHANGELOG.md](https://github.com/btclib-org/ecc/blob/main/CHANGELOG.md), + and [RELEASE_NOTES.md](https://github.com/btclib-org/ecc/blob/main/RELEASE_NOTES.md) for what a release asks a user to act on --- diff --git a/docs/source/conf.py b/docs/source/conf.py index 0c7803ca..e4ebe7d3 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -148,25 +148,28 @@ # README, CONTRIBUTING, REVIEWING, SECURITY, RELEASE_NOTES and CHANGELOG # -- each pulled into a *_link.md shim by a myst {include}. The # shims are what the code below reads, so adding one needs no edit here. -# Those files are written for the two places that read them unrendered -- -# the GitHub file view and the PyPI long description -- so -# "./SECURITY.md" is the correct spelling there and the one links.yml +# Those files are written for the places that read them unrendered: the +# GitHub file view and, for README.md, the PyPI long description. So a +# root file links another as "./SECURITY.md", the spelling links.yml # checks, resolving it as a path relative to the file. Sphinx sees them # lifted out of the tree that makes it correct, and myst resolves not one -# of those links. +# of those links. README.md links it as BLOB plus its path, PyPI +# resolving no relative link against the tree (section 2 of the +# organization standard), and myst renders that as a link to GitHub. # -# What it emits instead is the reason this needs code rather than a -# warning filter: a target myst cannot resolve becomes an anchor on the -# page it is already on, href="#./SECURITY.md", an id nothing has. The -# build succeeds, -W sees nothing, and lychee reads the sources, where the -# path is right. +# What myst emits for a relative link is the reason this needs code +# rather than a warning filter: a target myst cannot resolve becomes an +# anchor on the page it is already on, href="#./SECURITY.md", an id +# nothing has. The build succeeds, -W sees nothing, and lychee reads the +# sources, where the path is right. # # The transform below answers each link from the repository rather than # from a table that would have to be kept in step with this directory: a -# path a *_link.md shim includes becomes a reference to that page, any -# other path that exists in the tree becomes a link to the file on GitHub, -# and a path that exists nowhere is left to myst -- which reports it, and -# -W then fails, no suppress_warnings hiding the subtype. +# path a *_link.md shim includes becomes a reference to that page, +# whether written relative or as BLOB plus the path; any other relative +# path that exists in the tree becomes a link to the file on GitHub, and +# a relative path that exists nowhere is left to myst -- which reports +# it, and -W then fails, no suppress_warnings hiding the subtype. # # It also reads a link written this way *outside* a shim, resolved # against the document that wrote it rather than against ROOT. A page @@ -224,12 +227,19 @@ def included(shim: Path) -> tuple[str, str]: class RootFileLinks(SphinxPostTransform): - """Resolve the repository-relative links naming a file of this tree.""" + """Resolve the links naming a file of this tree.""" # ahead of myst's own resolver, which runs at 9 and is what turns an # unresolved target into that anchor default_priority = 5 + @staticmethod + def _to_page(node: pending_xref, target: str, anchor: str | None) -> None: + """Point a myst xref at the page rendering the included `target`.""" + node["refdomain"] = "doc" + node["reftarget"] = INCLUDED[target] + node["reftargetid"] = anchor or None + def _broken_doc_target(self, node: pending_xref) -> tuple[Path, str | None] | None: """Recover the file behind a "doc" xref `path2doc` resolved wrong. @@ -269,7 +279,7 @@ def _unbound_target( return origin / raw, anchor or None def run(self, **kwargs: Any) -> None: - """Rewrite every myst xref naming a file of this repository.""" + """Rewrite every link naming a file of this repository.""" # the list is taken before the tree is edited: replace_self on a # node the generator is standing on reparents its children under it for node in list(self.document.findall(pending_xref)): @@ -300,9 +310,7 @@ def run(self, **kwargs: Any) -> None: # handed back to myst as the link it would have been # written as, so the page title and the caption are its # business and not this file's - node["refdomain"] = "doc" - node["reftarget"] = INCLUDED[target] - node["reftargetid"] = anchor or None + self._to_page(node, target, anchor) elif (ROOT / target).is_file(): fragment = f"#{anchor}" if anchor else "" reference = nodes.reference( @@ -310,6 +318,30 @@ def run(self, **kwargs: Any) -> None: ) reference.extend(node.children) node.replace_self(reference) + self._blob_links() + + def _blob_links(self) -> None: + """Turn a BLOB link to an included file into a link to its page. + + myst made each BLOB link an external reference: one naming an + included file becomes the xref myst makes from a relative link, + which myst's resolver then renders alike. + """ + for node in list(self.document.findall(nodes.reference)): + url, _, anchor = node.get("refuri", "").partition("#") + target = url.removeprefix(BLOB) + if target == url or target not in INCLUDED: + continue + xref = pending_xref( + "", + nodes.inline("", "", *node.children, classes=["xref", "myst"]), + reftype="myst", + refexplicit=True, + refdoc=self.env.docname, + ) + xref.source, xref.line = node.source, node.line + self._to_page(xref, target, anchor) + node.replace_self(xref) def setup(app: Sphinx) -> None: diff --git a/pyproject.toml b/pyproject.toml index baff1275..cfa34f38 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -136,6 +136,9 @@ test = [ { include-group = "harness" }, { include-group = "bindings" }, { include-group = "gmpy2" }, + # tests/docs_conf_test.py executes docs/source/conf.py, which imports + # it; the marker and the bound are `docs`'s, below + "sphinx>=9.1.0; python_full_version >= '3.12'", ] lint = [ { include-group = "bindings" }, diff --git a/tests/docs_conf_test.py b/tests/docs_conf_test.py new file mode 100644 index 00000000..6d1a78ce --- /dev/null +++ b/tests/docs_conf_test.py @@ -0,0 +1,149 @@ +# Copyright (c) The btclib developers +# Distributed under the MIT software license, see the accompanying +# LICENSE file or https://opensource.org/license/mit for the full text. + +"""`RootFileLinks` in docs/source/conf.py links an included file to its page. + +Section 2 of the organization standard, *The documentation*, is the rule +these tests hold `RootFileLinks` to. They run the transform on a +hand-made doctree, so each case is checked whether or not a page of the +tree holds it: a fragment, and a page other than an included one. + +Executing conf.py imports sphinx, which the `test` group carries from +Python 3.12, the `docs` group's marker: on 3.11 the fixture skips. +""" + +import importlib.util +from pathlib import Path +from types import ModuleType, SimpleNamespace +from typing import Any + +import pytest + +_CONF = Path(__file__).parents[1] / "docs" / "source" / "conf.py" + + +@pytest.fixture(scope="module") +def conf() -> ModuleType: + """Return docs/source/conf.py, executed as a module.""" + pytest.importorskip( + "sphinx", + reason=( + "sphinx is not installed here, and conf.py imports it: the" + " `test` group carries it from Python 3.12, as `docs` does" + ), + ) + spec = importlib.util.spec_from_file_location("docs_conf", _CONF) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _run(conf: ModuleType, node: Any, docname: str = "readme_link") -> Any: + """Run `RootFileLinks` on a page holding `node`; return its replacement. + + Args: + conf: the conf.py module. + node: the link, as myst makes it. + docname: the page the link is on. + + Returns: + The node in the link's place once the transform has run. + """ + utils = importlib.import_module("docutils.utils") + document = utils.new_document("") + document.settings.env = SimpleNamespace(docname=docname) + paragraph = conf.nodes.paragraph() + paragraph += node + document += paragraph + conf.RootFileLinks(document).run() + return paragraph[0] + + +def _url(conf: ModuleType, url: str) -> Any: + """Return the reference myst makes from a link to an external `url`. + + Args: + conf: the conf.py module. + url: the link's destination. + + Returns: + The reference, its text the url. + """ + return conf.nodes.reference(url, url, refuri=url) + + +@pytest.mark.parametrize("docname", ["readme_link", "ecc"]) +@pytest.mark.parametrize("fragment", ["", "#reporting-a-vulnerability"]) +def test_blob_link_to_an_included_file_is_its_page( + conf: ModuleType, docname: str, fragment: str +) -> None: + """A `BLOB` link to an included file, on any page, becomes that page.""" + url = f"{conf.BLOB}SECURITY.md{fragment}" + node = _run(conf, _url(conf, url), docname) + assert isinstance(node, conf.pending_xref) + assert node["reftype"] == "myst" + assert node["refdomain"] == "doc" + assert node["reftarget"] == conf.INCLUDED["SECURITY.md"] + assert node["reftargetid"] == (fragment.removeprefix("#") or None) + assert node["refexplicit"] + assert node["refdoc"] == docname + assert node.astext() == url + + +@pytest.mark.parametrize("fragment", ["", "#reporting-a-vulnerability"]) +def test_blob_and_relative_links_give_the_same_xref( + conf: ModuleType, fragment: str +) -> None: + """A `BLOB` link and a relative one to the same file resolve alike.""" + relative = conf.pending_xref( + "", + conf.nodes.inline("", "SECURITY.md"), + reftype="myst", + refdomain=None, + reftarget=f"./SECURITY.md{fragment}", + refdoc="readme_link", + ) + from_relative = _run(conf, relative) + from_blob = _run(conf, _url(conf, f"{conf.BLOB}SECURITY.md{fragment}")) + for key in ("reftype", "refdomain", "reftarget", "reftargetid", "refdoc"): + assert from_blob[key] == from_relative[key], key + + +@pytest.mark.parametrize( + "path", + [ + # BLOB plus a file of the tree no shim includes + "LICENSE", + # BLOB plus a path that is not a file of the tree + "NO-SUCH-FILE.md", + ], +) +def test_blob_link_to_a_file_not_included_stays_on_github( + conf: ModuleType, path: str +) -> None: + """A `BLOB` link to a file no page renders is left as it is.""" + assert path not in conf.INCLUDED + url = f"{conf.BLOB}{path}" + node = _run(conf, _url(conf, url)) + assert isinstance(node, conf.nodes.reference) + assert node["refuri"] == url + + +@pytest.mark.parametrize( + "url", + [ + # another repository's file of the same name + "https://github.com/btclib-org/.github/blob/main/SECURITY.md", + # this repository's file, on a branch other than main + "https://github.com/btclib-org/ecc/blob/other/SECURITY.md", + ], +) +def test_github_link_other_than_blob_is_left(conf: ModuleType, url: str) -> None: + """A GitHub link that is not `BLOB` plus a path is left as it is.""" + assert not url.startswith(conf.BLOB) + node = _run(conf, _url(conf, url)) + assert isinstance(node, conf.nodes.reference) + assert node["refuri"] == url diff --git a/uv.lock b/uv.lock index eebe2529..ef75dfe6 100644 --- a/uv.lock +++ b/uv.lock @@ -1047,6 +1047,7 @@ test = [ { name = "pytest-cov" }, { name = "pytest-randomly" }, { name = "pytest-xdist" }, + { name = "sphinx", marker = "python_full_version >= '3.12'" }, ] [package.metadata] @@ -1120,6 +1121,7 @@ test = [ { name = "pytest-cov", specifier = ">=7.1.0" }, { name = "pytest-randomly", specifier = ">=5.0.0" }, { name = "pytest-xdist", specifier = ">=3.8.0" }, + { name = "sphinx", marker = "python_full_version >= '3.12'", specifier = ">=9.1.0" }, ] [[package]]