diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 24c7ee6..4eae8a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,6 +90,14 @@ jobs: - name: Every module is documented run: python3 tools/check_module_docs.py + # The counts quoted in Cargo.toml, pyproject.toml and the READMEs are + # written by hand from the two generated files above. This pins them + # to those files, so a library that grows fails the build instead of + # shipping a stale figure to a registry, where the description of a + # published version cannot be edited. + - name: Quoted counts match the generated figures + run: python3 tools/check_counts.py + coverage: name: Coverage runs-on: ubuntu-latest diff --git a/.github/workflows/crates-release.yml b/.github/workflows/crates-release.yml index d797989..32f7f03 100644 --- a/.github/workflows/crates-release.yml +++ b/.github/workflows/crates-release.yml @@ -1,37 +1,7 @@ name: Crates.io release -# Publishes the library crate to crates.io. Its sibling python-release.yml -# publishes the bindings to PyPI, and both answer to `push: tags: v*`, so one -# tag ships both registries. -# -# A file of its own rather than a job in python-release.yml, for the same -# reason that file gives for existing: a trusted publisher is registered -# against a workflow *filename*. One file per registry means either -# publisher can be reconfigured without disturbing the other, and a failure -# on one registry does not strand the other mid-run. -# -# ── How the upload is authorised ───────────────────────────────────── -# -# crates.io Trusted Publishing, the same OIDC exchange PyPI uses, so there -# is no registry token in this repository's secrets. The publisher -# registered on crates.io for `rust_physics_engine` names: -# -# repository Magic-Man-us/RustPhysicsEngine -# workflow crates-release.yml <- this file's name -# environment crates-io <- the job's environment, below -# -# crates-io-auth-action trades the workflow's OIDC token for a crates.io -# token scoped to this run, and revokes it in its post step. Renaming this -# file or changing that environment breaks the release until crates.io is -# told. -# -# ── Why workflow_dispatch ──────────────────────────────────────────── -# -# v0.2.0 was tagged before this file existed, so no tag push can ever fire -# it for that version. Dispatch runs from the default branch and checks out -# whichever existing tag it is given, which is the only way to publish a -# version whose tag predates this workflow. From v0.3.0 on the tag push is -# enough and this input should go unused. +# The crates.io trusted publisher is keyed to this file's name and the +# environment below. Renaming either silently breaks releases. on: push: @@ -39,6 +9,7 @@ on: workflow_dispatch: inputs: tag: + # For versions tagged before this file existed; no tag push can reach them. description: "An existing tag to publish, e.g. v0.2.0" required: true type: string @@ -52,20 +23,13 @@ jobs: publish: name: "Publish to crates.io" runs-on: ubuntu-latest - # Named so crates.io can be told to trust exactly this job, and so a - # required reviewer on the environment gates the one step here that - # cannot be undone: a published version can be yanked, never removed. environment: name: crates-io url: "https://crates.io/crates/rust_physics_engine" permissions: contents: read - # The OIDC token Trusted Publishing exchanges for an upload. The only - # elevated permission in the file, scoped to this job. id-token: write steps: - # A tag push carries the tag in ref_name; a dispatch carries it in the - # input. Both paths continue as one value from here. - name: The tag being published id: ref run: 'echo "tag=${{ inputs.tag || github.ref_name }}" >> "$GITHUB_OUTPUT"' @@ -75,19 +39,12 @@ jobs: ref: "${{ steps.ref.outputs.tag }}" persist-credentials: false - # The same gate python-release.yml runs, and the reason a malformed or - # mismatched tag stops here: check_version.py rejects anything that is - # not vX.Y.Z, and refuses a tag whose version disagrees with the - # manifests. crates.io will not re-use a version number either. - name: The tag, the crate and the bindings agree run: "python3 bindings/python/check_version.py '${{ steps.ref.outputs.tag }}'" - uses: rust-lang/crates-io-auth-action@v1 id: auth - # Verification is left on: the crate is dependency-free and builds in - # seconds, so the packaged artifact is compiled before upload rather - # than trusted. - name: Publish run: "cargo publish" env: diff --git a/Cargo.toml b/Cargo.toml index e14fa1e..4c2b068 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "rust_physics_engine" version = "0.2.0" edition = "2021" -description = "A zero-dependency Rust library for physics, mathematics and engineering computation — 6,365 public functions across 71 modules" +description = "A zero-dependency Rust library for physics, mathematics and engineering computation — 4,124 public functions across 295 modules" license = "MIT" repository = "https://github.com/Magic-Man-us/RustPhysicsEngine" homepage = "https://github.com/Magic-Man-us/RustPhysicsEngine" diff --git a/bindings/python/README.md b/bindings/python/README.md index 318dd32..24ba724 100644 --- a/bindings/python/README.md +++ b/bindings/python/README.md @@ -148,7 +148,7 @@ extension. ## What is not bound 4,086 of the library's 4,149 free functions, 2,254 of its 2,277 methods, -416 of its 426 types and all 106 of its constants, across 296 modules. +416 of its 426 types and all 106 of its constants, across 295 modules. The rest is mostly three things: functions generic over a type parameter, which cannot be monomorphised without knowing what to monomorphise to; diff --git a/bindings/python/pyproject.toml b/bindings/python/pyproject.toml index 5db9f9b..a65b82f 100644 --- a/bindings/python/pyproject.toml +++ b/bindings/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "numeria" -description = "Physics, mathematics and engineering computation: 4,000+ validated functions across 71 domains, in Rust, callable from Python" +description = "Physics, mathematics and engineering computation: 4,086 functions across 71 domains, in Rust, callable from Python" readme = "README.md" license = { text = "MIT" } requires-python = ">=3.9" diff --git a/tools/check_counts.py b/tools/check_counts.py new file mode 100755 index 0000000..f686c81 --- /dev/null +++ b/tools/check_counts.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Check counts quoted in prose against the files that derive them. + +MODULE_MAP.md and COVERAGE.md disagree by design -- the map counts a +macro-generated item once, the generator counts what it emitted -- so each +claim names which of them owns it. +""" + +from __future__ import annotations + +import os +import re +import sys + +ROOT = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) +MODULE_MAP = "docs/MODULE_MAP.md" +COVERAGE = "bindings/python/COVERAGE.md" + + +def read(rel: str) -> str: + with open(os.path.join(ROOT, rel), encoding="utf-8") as fh: + return fh.read() + + +def plain(n: str) -> int: + return int(n.replace(",", "")) + + +def module_map_facts() -> dict[str, int]: + text = read(MODULE_MAP) + pairs = { + "modules": r"\*\*([\d,]+) modules\*\*", + "top_level": r"\*\*([\d,]+) public top-level modules\*\*", + "files": r"\*\*([\d,]+) files\*\*", + "map_functions": r"\*\*([\d,]+) public functions\*\*", + "map_methods": r"\*\*([\d,]+) public methods\*\*", + "map_types": r"\*\*([\d,]+) public types\*\*", + } + out = {} + for key, pat in pairs.items(): + m = re.search(pat, text) + if not m: + sys.exit(f"{MODULE_MAP}: could not find the {key} figure -- regenerate it") + out[key] = plain(m.group(1)) + return out + + +def coverage_facts() -> dict[str, int]: + text = read(COVERAGE) + rows = { + "functions": "Free functions", + "methods": "Methods", + "classes": "Classes", + "constants": "Constants", + } + out = {} + for key, label in rows.items(): + m = re.search(rf"^\|\s*{label}\s*\|\s*(\d+)\s*\|\s*(\d+)\s*\|", text, re.M) + if not m: + sys.exit(f"{COVERAGE}: could not find the {label!r} row -- regenerate it") + out[f"rust_{key}"] = int(m.group(1)) + out[f"bound_{key}"] = int(m.group(2)) + return out + + +# A literal space in a pattern means any whitespace: these sentences are +# hard wrapped and a reflow must not read as a missing claim. +CLAIMS: list[tuple[str, str, str]] = [ + ("Cargo.toml", r"([\d,]+) public functions", "map_functions"), + ("Cargo.toml", r"public functions across ([\d,]+) modules", "modules"), + ("README.md", r"the ([\d,]+) modules", "modules"), + ("docs/GUIDE.md", r"([\d,]+) modules", "modules"), + ("bindings/python/pyproject.toml", r"([\d,]+) functions across", "bound_functions"), + ("bindings/python/pyproject.toml", r"functions across ([\d,]+) domains", "top_level"), + ("bindings/python/README.md", r"^([\d,]+) functions, ", "bound_functions"), + ("bindings/python/README.md", r"functions, ([\d,]+) methods", "bound_methods"), + ("bindings/python/README.md", r"methods, ([\d,]+) classes", "bound_classes"), + ("bindings/python/README.md", r"classes and ([\d,]+) constants", "bound_constants"), + ("bindings/python/README.md", r"constants across ([\d,]+) domains", "top_level"), + ("bindings/python/README.md", r"^([\d,]+) of the library's", "bound_functions"), + ("bindings/python/README.md", r"library's ([\d,]+) free functions", "rust_functions"), + ("bindings/python/README.md", r"free functions, ([\d,]+) of its", "bound_methods"), + ("bindings/python/README.md", r"of its ([\d,]+) methods", "rust_methods"), + ("bindings/python/README.md", r"methods, ([\d,]+) of its", "bound_classes"), + ("bindings/python/README.md", r"of its ([\d,]+) types", "rust_classes"), + ("bindings/python/README.md", r"all ([\d,]+) of its constants", "bound_constants"), + ("bindings/python/README.md", r"constants, across ([\d,]+) modules", "modules"), +] + + +def compile_claim(pattern: str) -> re.Pattern[str]: + return re.compile(pattern.replace(" ", r"\s+"), re.M) + + +def main() -> int: + facts = module_map_facts() | coverage_facts() + problems: list[str] = [] + for rel, pattern, key in CLAIMS: + text = read(rel) + m = compile_claim(pattern).search(text) + if not m: + problems.append(f"{rel}: no text matching {pattern!r} -- the sentence moved") + continue + found, want = plain(m.group(1)), facts[key] + if found != want: + line = text[: m.start(1)].count("\n") + 1 + problems.append( + f"{rel}:{line}: says {found:,} but {key} is {want:,}" + ) + if problems: + print("count check failed:", file=sys.stderr) + for p in problems: + print(f" {p}", file=sys.stderr) + print( + f"\nthe figures come from {MODULE_MAP} and {COVERAGE}, both generated;" + "\nfix the prose, not those files", + file=sys.stderr, + ) + return 1 + print(f"{len(CLAIMS)} quoted counts agree with {MODULE_MAP} and {COVERAGE}") + return 0 + + +if __name__ == "__main__": + sys.exit(main())