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
12 changes: 12 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,18 @@ repos:
entry: |-
(?<![`!\[])(?:!?\[(?:[^]]|\]\([^)]*\))*\]\(|^\x20{0,3}\[[^]]+\]:\s*)(?!\.\.?/|#|[A-Za-z][A-Za-z0-9+.-]*:)[^)\s]
types: [markdown]
exclude: ^README\.md$
# the root README.md is the package's page on the index, where a
# relative link reaches nothing: section 2 of the organization
# standard is the rule. The pattern above with "./" and "../" no
# longer admitted
- id: readme-absolute-link
name: the root README.md links a file of the tree absolutely
language: pygrep
entry: |-
(?<![`!\[])(?:!?\[(?:[^]]|\]\([^)]*\))*\]\(|^\x20{0,3}\[[^]]+\]:\s*)(?!#|[A-Za-z][A-Za-z0-9+.-]*:)[^)\s]
files: ^README\.md$
types: [markdown]
# a line that ends inside a word, at that word's own hyphen.
# Section 4 of the organization standard is the rule: what such a
# line renders as, why nothing else here catches it, what this
Expand Down
40 changes: 22 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,8 @@ It is fully annotated and ships `py.typed`.
- the ANSI X9.63 key derivation function and HKDF (`kdf`), and the BIP340
tagged hash (`hashes`)

[ARCHITECTURE.md](./ARCHITECTURE.md) says how these modules layer and
which may import which.
[ARCHITECTURE.md](https://github.com/btclib-org/ecc/blob/main/ARCHITECTURE.md)
says how these modules layer and which may import which.

For secp256k1 the arithmetic is delegated to
[btclib-secp256k1](https://github.com/btclib-org/btclib-secp256k1),
Expand All @@ -114,13 +114,14 @@ Without them, or with the delegation turned off, every call still
answers, on the Python arithmetic, which is slower and not constant-time;
that Python arithmetic serves every other curve anyway, and the suite
validates it against the bindings.
[SECURITY.md](./SECURITY.md)'s "Limitations, not vulnerabilities" states
which calls delegate and what the Python path does and does not hide.
[SECURITY.md](https://github.com/btclib-org/ecc/blob/main/SECURITY.md)'s
"Limitations, not vulnerabilities" states which calls delegate and what
the Python path does and does not hide.

The suite answers to vectors their authors publish -- the BIPs' own,
Wycheproof's and libsecp256k1-zkp's -- and
[tests/_data/README.md](./tests/_data/README.md) pins each vendored file
to the upstream commit it was copied from.
[tests/_data/README.md](https://github.com/btclib-org/ecc/blob/main/tests/_data/README.md)
pins each vendored file to the upstream commit it was copied from.

## Installing

Expand Down Expand Up @@ -174,24 +175,26 @@ and `tests/readme_test.py` runs every Python block of this file.

## Security

[SECURITY.md](./SECURITY.md) says how to report a vulnerability, which
versions are supported, and how a published file is traced back to the
run that built it.
[ASSURANCE_CASE.md](./ASSURANCE_CASE.md) argues why the security it states
holds: the threat model, the trust boundaries, the design principles and
the weaknesses countered.
[SECURITY.md](https://github.com/btclib-org/ecc/blob/main/SECURITY.md)
says how to report a vulnerability, which versions are supported, and
how a published file is traced back to the run that built it.
[ASSURANCE_CASE.md](https://github.com/btclib-org/ecc/blob/main/ASSURANCE_CASE.md)
argues why the security it states holds: the threat model, the trust
boundaries, the design principles and the weaknesses countered.

## Contributing

Development is LLM-based: humans are welcome, as long as they are
comfortable with agentic development ;-). A contribution must have been
read and understood by the person who submits it.
[`CONTRIBUTING.md`](./CONTRIBUTING.md#working-with-an-llm) has the rules.
[`CONTRIBUTING.md`](https://github.com/btclib-org/ecc/blob/main/CONTRIBUTING.md#working-with-an-llm)
has the rules.

[CONTRIBUTING.md](./CONTRIBUTING.md) has the commands each CI job runs,
verbatim. `uv sync` creates the environment; uv is the only tool that has
to be installed. [REVIEWING.md](./REVIEWING.md) is what a pull request is
answered against.
[CONTRIBUTING.md](https://github.com/btclib-org/ecc/blob/main/CONTRIBUTING.md)
has the commands each CI job runs, verbatim. `uv sync` creates the
environment; uv is the only tool that has to be installed.
[REVIEWING.md](https://github.com/btclib-org/ecc/blob/main/REVIEWING.md)
is what a pull request is answered against.

How the organization decides, and who holds which role, is its
[GOVERNANCE.md](https://github.com/btclib-org/.github/blob/main/GOVERNANCE.md);
Expand All @@ -203,7 +206,8 @@ what it intends to do, and what it deliberately does not, is its
- Documentation: <https://btclib-ecc.readthedocs.io/>
- Source: <https://github.com/btclib-org/ecc>
- Releases: <https://github.com/btclib-org/ecc/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

---
Expand Down
68 changes: 50 additions & 18 deletions docs/source/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)):
Expand Down Expand Up @@ -300,16 +310,38 @@ 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(
"", "", refuri=f"{BLOB}{target}{fragment}", internal=False
)
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:
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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'",
Comment thread
fametrano marked this conversation as resolved.
Resolved
]
lint = [
{ include-group = "bindings" },
Expand Down
149 changes: 149 additions & 0 deletions tests/docs_conf_test.py
Original file line number Diff line number Diff line change
@@ -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("<test>")
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
2 changes: 2 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading