diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index ac033fb..13ed555 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -1,6 +1,6 @@ # Epythet -Beautiful, correct documentation from a Python package, with no boilerplate in the package. Sphinx 9 underneath; README as landing page, nested API tree, themes, build-time docstring normalizer, agent-facing outputs, validation with a ledger of known artifacts, GitHub Pages publishing. "Less humdrum, more automation, earlier at the pub." +Beautiful, correct documentation from a Python package, with no boilerplate in the package. Sphinx 9 underneath; README as landing page, nested API tree, themes, build-time docstring normalizer, agent-facing outputs, build provenance (footer line, about-this-build page, build_info.json), validation with a ledger of known artifacts, GitHub Pages publishing. "Less humdrum, more automation, earlier at the pub." This file is the map: where things are and which artifact to read for which task. Content lives in the files it points at. @@ -15,13 +15,14 @@ This file is the map: where things are and which artifact to read for which task ``` epythet/ __init__.py # public API re-exports; quickstart() - cli.py # cw-based CLI: make-docsrc make-autodocs make quickstart check-pages configure-pages validate ai-artifacts ai-readme-check; groups ledger, snippets + cli.py # cw-based CLI: make-docsrc make-autodocs make quickstart check-pages configure-pages validate ai-artifacts ai-readme-check build-info; groups ledger, snippets config.py # DocsConfig SSOT: pyproject [project] + [tool.epythet], setup.cfg fallback confgen.py # DocsConfig -> Sphinx conf namespace (sphinx_settings) sphinx_conf.py # the star-import target of the generated two-line conf.py sphinx_ext.py # Sphinx extension: normalizer hook, link relations, theme CSS scaffold.py # writes docsrc/ (conf.py shim, index.md, extra PageSpec pages) - build.py # runs sphinx-build; agent outputs and aggregates after html + build.py # runs sphinx-build; provenance before, agent outputs and aggregates after html + provenance.py # build_info record (git, CI, versions, PyPI), footer line, about-this-build page templates.py # text of the generated files normalizer.py # build-time docstring rewrites (pure functions, DEFAULT_RULES) themes.py # curated theme registry, theme="auto", OKLCH accent @@ -44,7 +45,7 @@ tests/ # pytest; Sphinx smoke build in test_b - **CLI** uses `cw`: a command is a plain function with keyword-only options, appended to `COMMANDS` in `cli.py`. `tests/test_cli.py` holds usage goldens; adding a command means updating them deliberately. - **Config** keys are `DocsConfig` fields; unknown `[tool.epythet]` keys raise `ConfigError`. Booleans and lists coerced from `setup.cfg` strings via `_BOOL_KEYS` / `_LIST_KEYS`. -- **Seams are keyword arguments**: `api_generator`, `theme`, `agent_outputs` / `aggregates`, normalizer `rules`, `scaffold(pages=)`, `validate(backend=, ledger=)`, `ai_artifacts_template` (and `EPYTHET_AI_ARTIFACTS=0` as the fleet-wide off switch). +- **Seams are keyword arguments**: `api_generator`, `theme`, `agent_outputs` / `aggregates`, normalizer `rules`, `scaffold(pages=)`, `validate(backend=, ledger=)`, `ai_artifacts_template` (and `EPYTHET_AI_ARTIFACTS=0` as the fleet-wide off switch), `provenance` (`true` / `"minimal"` / `false`) and `provenance_template` (`EPYTHET_PYPI_CHECK=0` skips the network lookup, `SOURCE_DATE_EPOCH` fixes the build time). - **Generated files carry a marker** (``, `from epythet.sphinx_conf import *`) so epythet overwrites only its own output; hand-written files are kept. - **GitHub API** access: `GITHUB_TOKEN` + `requests` when available, else the `gh` CLI (`published_docs.py`). - **Optional dependencies** are imported lazily under `suppress(ImportError)`: `pandas`, `hubcap`, `tec`, `pyyaml` (validate), `playwright` / `weasyprint` (pdf). diff --git a/README.md b/README.md index 7cd7456..48cd024 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ Open `/path/to/project/docsrc/_build/html/index.html`. You get: - a modern theme with light/dark mode and an accent colour derived from your package name, - **agent-facing twins**: `llms.txt`, a `.md` twin of every page, a flat `.md`, and `objects.inv`. +Every site also says where it came from. A small line at the bottom of the landing page reads `built from () · · about this build`, so a reader can tell whether the docs match the repository and the installed package, and a maintainer can see whether the latest push has been published. The `about-this-build` page behind the link holds the full diagnosis (commit, tags, dirty flag, CI run, tool versions, resolved configuration, latest PyPI release and whether it matches, how to reproduce the build), and `build_info.json` at the site root holds the same for machines; `epythet build-info DIR` prints it. `[tool.epythet] provenance = false` turns it off, `"minimal"` keeps the line and the JSON without the page, and `provenance_template` points at your own page template. + Nothing has to be added to the package. Everything is read from `pyproject.toml` (or `setup.cfg`), the README and the docstrings. diff --git a/actions/publish-github-pages/action.yml b/actions/publish-github-pages/action.yml index 1b5ac6a..969a4de 100644 --- a/actions/publish-github-pages/action.yml +++ b/actions/publish-github-pages/action.yml @@ -45,6 +45,27 @@ runs: with: fetch-depth: 0 + - name: Build from the branch tip + # The wads CI publishes to PyPI, then pushes a version-bump commit marked + # [skip ci] before this job runs, so the event SHA is the commit *before* + # the bump. Fast-forward to the tip so the site is built from the + # released version, with its tag, and epythet's provenance footer can + # compare it with PyPI. Never fails the job: on any problem the event + # commit is built as before. + env: + # Through env, never interpolated into the script: ref names may contain shell syntax. + REF_NAME: ${{ github.ref_name }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + if [ -n "$DEFAULT_BRANCH" ] && [ "$REF_NAME" = "$DEFAULT_BRANCH" ]; then + if git fetch --quiet --tags origin "$REF_NAME" && git merge --ff-only --quiet FETCH_HEAD; then + echo "Building $(git rev-parse --short HEAD) (tip of $REF_NAME; event commit ${GITHUB_SHA::7})" + else + echo "::notice::Could not fast-forward to origin/$REF_NAME; building the event commit ${GITHUB_SHA::7}" + fi + fi + shell: bash + - name: Set up Python ${{ inputs.python-version }} uses: actions/setup-python@v5 with: diff --git a/epythet/__init__.py b/epythet/__init__.py index 858e2e5..bac3dd2 100644 --- a/epythet/__init__.py +++ b/epythet/__init__.py @@ -32,6 +32,11 @@ (``epythet ai-readme-check``), with the user's policy and text snippets from :mod:`epythet.userconfig` (``~/.config/epythet``). +Every site states its provenance: a one-line footer on the landing page (build +time, commit, package version), an ``about-this-build`` page with the full +diagnosis and a ``build_info.json`` for machines, see :mod:`epythet.provenance` +and :func:`collect_build_info`. + GitHub Pages helpers (:func:`check_pages_setup`, :func:`enable_pages`) and docstring diagnosis tools (:func:`diagnose_doctest_code_blocks`, :func:`repair_package`) live in :mod:`epythet.tools`. @@ -47,6 +52,7 @@ from epythet.ai_artifacts import discover_artifacts, ai_artifacts_page from epythet.agentic_readme import check_readme, render_section, write_section from epythet.userconfig import config_dir, load_user_config, snippet_text +from epythet.provenance import collect_build_info from epythet.tools import ( repair_package, diff --git a/epythet/build.py b/epythet/build.py index 6dbff2c..6073455 100644 --- a/epythet/build.py +++ b/epythet/build.py @@ -8,6 +8,13 @@ Targets mirror the old Makefile: ``html`` (the default), ``doctest``, ``markdown``, ``github`` (``html`` then a copy into ``PROJECT_DIR/docs``), ``gitlab`` (copy into ``public``) and ``clean``. + +An ``html`` build also carries its provenance (:mod:`epythet.provenance`): the +record is collected here, once, before Sphinx runs; the about page's source is +written into ``docsrc``; the record reaches the Sphinx process through the +``EPYTHET_BUILD_INFO`` environment variable, where the extension renders the +footer and writes ``build_info.json``; afterwards ``llms.txt`` and the +``.md`` aggregate get a pointer to that file. """ from __future__ import annotations @@ -21,9 +28,24 @@ from epythet.agent_outputs import inject_link_relations_into_site, write_aggregates from epythet.config import DocsConfig, load_config +from epythet.provenance import ( + ABOUT_PAGE_DOCNAME, + ABOUT_PAGE_FILENAME, + BUILD_INFO_ENV, + BUILD_INFO_FILENAME, + about_page, + about_template, + collect_build_info, + prune_site, + reference_from_agent_outputs, +) +from epythet.scaffold import remove_generated_file, write_generated_file +from epythet.templates import INDEX_MARKER BUILD_DIRNAME = "_build" COPY_TARGETS = {"github": "docs", "gitlab": "public"} +#: Builders that produce a browsable site and therefore carry provenance. +HTML_TARGETS = ("html", "dirhtml") class BuildError(RuntimeError): @@ -65,6 +87,12 @@ def build( html_dir = build(config, "html", overrides=overrides) destination = config.project_dir / COPY_TARGETS[target] shutil.copytree(html_dir, destination, dirs_exist_ok=True) + # copytree never deletes: drop provenance files the fresh site no longer has. + prune_site( + destination, + keep_page=(html_dir / f"{ABOUT_PAGE_DOCNAME}.html").is_file(), + keep_json=(html_dir / BUILD_INFO_FILENAME).is_file(), + ) return destination outdir = build_dir / target @@ -86,13 +114,55 @@ def build( env = dict(os.environ) env["EPYTHET_PROJECT_DIR"] = str(config.project_dir) env["EPYTHET_OVERRIDES"] = json.dumps(overrides) + info = prepare_provenance(config) if target in HTML_TARGETS else None + if info is not None: + env[BUILD_INFO_ENV] = json.dumps(info) result = subprocess.run(command, cwd=str(docsrc), env=env) if result.returncode != 0: raise BuildError( f"sphinx-build -b {target} failed with exit status {result.returncode} " f"(sources: {docsrc})" ) + if target in HTML_TARGETS: + # A source file that survived prepare_provenance is hand-written: keep its page. + prune_site( + outdir, + keep_page=(config.docsrc_dir / ABOUT_PAGE_FILENAME).is_file(), + keep_json=info is not None, + ) if target == "html" and config.agent_outputs: inject_link_relations_into_site(outdir) write_aggregates(outdir, package_name=config.name, aggregates=config.aggregates) + if info is not None: + reference_from_agent_outputs(outdir, info, package_name=config.name) return outdir + + +def prepare_provenance(config: DocsConfig) -> dict | None: + """Collect the build record and write the about page's source; ``None`` when off. + + Runs before Sphinx so the page is part of the build. The page is removed + (when it is epythet's own) for ``provenance = false`` and ``"minimal"``, + so a switched-off project never publishes a stale one. A failure to + collect is reported once and the build goes on without provenance; a + wrong ``provenance_template`` is a :class:`~epythet.config.ConfigError`, + like any other configuration mistake. + """ + target = config.docsrc_dir / ABOUT_PAGE_FILENAME + if not config.provenance: + remove_generated_file(target, markers=(INDEX_MARKER,)) + return None + template = about_template(config) + try: + info = collect_build_info(config) + except Exception as e: # provenance never fails a build + print(f"epythet: build provenance unavailable ({e})", file=sys.stderr) + return None + if config.provenance == "minimal": + remove_generated_file(target, markers=(INDEX_MARKER,)) + else: + page = about_page(info, template=template) + write_generated_file(target, page.content, markers=(page.marker,)) + for warning in info["warnings"]: + print(f"epythet: provenance: {warning}", file=sys.stderr) + return info diff --git a/epythet/cli.py b/epythet/cli.py index 5ca5e19..5db6853 100644 --- a/epythet/cli.py +++ b/epythet/cli.py @@ -115,6 +115,28 @@ def ai_artifacts(project_dir, *, format: str = "table"): print(artifacts_table(found, repo_stub=repo_stub)) +def build_info(project_dir, *, no_pypi: bool = False): + """Print the build provenance record for a project as JSON. + + The same record that an ``html`` build writes to ``build_info.json`` at the + site root and renders on the about-this-build page: package name and + version, git commit/branch/tags/dirty flag, CI context, tool versions, the + resolved configuration, the latest PyPI release and whether the docs and + the package look aligned. The documented-module counts need a build and + are ``null`` here. + + :param project_dir: the project root + :param no_pypi: skip the PyPI lookup (also ``EPYTHET_PYPI_CHECK=0``) + """ + import json + + from epythet.provenance import collect_build_info + + config = load_config(project_dir) + info = collect_build_info(config, check_pypi=False if no_pypi else None) + print(json.dumps(info, indent=2)) + + def _resolve_repo_stub(repo): """Resolve a repo argument to an owner/repo slug.""" if "/" in repo and not repo.startswith("/") and not repo.startswith("."): @@ -143,6 +165,7 @@ def _resolve_repo_stub(repo): validate, ai_artifacts, ai_readme_check, + build_info, ] #: The v2 source-editing and fleet commands, by their command-line name. diff --git a/epythet/confgen.py b/epythet/confgen.py index e254c7a..f569205 100644 --- a/epythet/confgen.py +++ b/epythet/confgen.py @@ -52,8 +52,16 @@ ) -def sphinx_settings(config: DocsConfig) -> dict[str, Any]: - """The complete Sphinx ``conf.py`` namespace for ``config``.""" +def sphinx_settings( + config: DocsConfig, *, build_info: dict[str, Any] | None = None +) -> dict[str, Any]: + """The complete Sphinx ``conf.py`` namespace for ``config``. + + :param build_info: the provenance record of this build + (:func:`epythet.provenance.collect_build_info`), rendered by the + extension as the landing-page footer and ``build_info.json``; ``None`` + renders nothing. + """ theme = resolve_theme( config.package_name, theme=config.theme, @@ -113,6 +121,8 @@ def sphinx_settings(config: DocsConfig) -> dict[str, Any]: "epythet_theme_css": theme.css, "epythet_agent_outputs": config.agent_outputs, "epythet_normalizer_rules": None, + "epythet_provenance": config.provenance, + "epythet_build_info": build_info if config.provenance else None, } settings = merge_settings(settings, _api_generator_settings(config)) if config.agent_outputs: diff --git a/epythet/config.py b/epythet/config.py index 6499639..d3dfcbc 100644 --- a/epythet/config.py +++ b/epythet/config.py @@ -29,6 +29,8 @@ aggregates = ["md"] # flat single-document twins at the site root ai_artifacts = true # "For AI agents" page when skills/agents/CLAUDE.md exist ai_artifacts_template = "" # project-relative file overriding that page's template + provenance = true # build footer, about-this-build page, build_info.json; "minimal": no page + provenance_template = "" # project-relative file overriding the about-this-build page template package_dir = "src/dol" # default: found by convention docs_dir = "docsrc" # where the Sphinx sources live @@ -90,6 +92,7 @@ #: Seconds allowed for the import probe behind ``api_generator = "auto"``. IMPORT_PROBE_TIMEOUT = 120 VALID_AGGREGATES = ("md", "pdf") +VALID_PROVENANCE = (True, False, "minimal") class ConfigError(ValueError): @@ -123,6 +126,8 @@ class DocsConfig: aggregates: tuple[str, ...] = ("md",) ai_artifacts: bool = True ai_artifacts_template: str = "" + provenance: bool | str = True + provenance_template: str = "" package_dir: Path | None = None docs_dir: str = DEFAULT_DOCS_DIR @@ -141,6 +146,10 @@ def __post_init__(self): raise ConfigError( f"aggregates may only contain {VALID_AGGREGATES}; got {sorted(unknown)}" ) + if self.provenance not in VALID_PROVENANCE: + raise ConfigError( + f'provenance must be true, false or "minimal", not {self.provenance!r}' + ) object.__setattr__(self, "ignore", split_ignore(self.ignore)) object.__setattr__(self, "aggregates", tuple(self.aggregates)) object.__setattr__(self, "project_dir", Path(self.project_dir).absolute()) @@ -380,6 +389,13 @@ def _coerce_tool_fields(tool: dict[str, Any]) -> dict[str, Any]: ] elif key in _BOOL_KEYS and isinstance(value, str): value = value.strip().lower() in ("1", "true", "yes", "on") + elif key == "provenance" and isinstance(value, str): + lowered = value.strip().lower() + value = ( + lowered + if lowered == "minimal" + else lowered in ("1", "true", "yes", "on") + ) elif key in ("theme_options", "readme") and not isinstance(value, dict): raise ConfigError(f"[tool.epythet.{key}] must be a table") out[key] = value diff --git a/epythet/data/skills/epythet-setup/SKILL.md b/epythet/data/skills/epythet-setup/SKILL.md index 5ad9348..a119cf0 100644 --- a/epythet/data/skills/epythet-setup/SKILL.md +++ b/epythet/data/skills/epythet-setup/SKILL.md @@ -64,6 +64,8 @@ agent_outputs = true # llms.txt, .md twins, re aggregates = ["md"] # flat single-document twins at the site root: "md", "pdf" ai_artifacts = true # "For AI agents" page when skills / subagents / CLAUDE.md exist ai_artifacts_template = "" # project-relative file overriding that page's template +provenance = true # landing-page build line + about-this-build page + build_info.json; "minimal": no page; false: nothing +provenance_template = "" # project-relative file overriding the about-this-build page template package_dir = "src/dol" # default: found by convention docs_dir = "docsrc" # where the Sphinx sources are generated @@ -90,7 +92,7 @@ and an `index.md` that includes the README with a hidden toctree. API pages and Overrides that must survive regeneration go **below the import** in the shim: anything defined there wins over the generated value. -Other commands: `epythet make PROJECT_DIR [html|doctest|markdown|github|clean]` runs `sphinx-build` with the current interpreter (`github` copies HTML into `PROJECT_DIR/docs`). `epythet validate PROJECT_DIR` checks docstrings (see `epythet-validate`). `epythet ai-artifacts PROJECT_DIR` lists a repo's skills and agents (see `epythet-ai-artifacts`). +Other commands: `epythet make PROJECT_DIR [html|doctest|markdown|github|clean]` runs `sphinx-build` with the current interpreter (`github` copies HTML into `PROJECT_DIR/docs`). `epythet validate PROJECT_DIR` checks docstrings (see `epythet-validate`). `epythet ai-artifacts PROJECT_DIR` lists a repo's skills and agents (see `epythet-ai-artifacts`). `epythet build-info PROJECT_DIR` prints the build provenance record (commit, branch, dirty flag, package version, tool versions, PyPI comparison) that every built site carries as a one-line footer on the landing page, an `about-this-build.html` page and `build_info.json` at the site root; it never fails a build (no git, no network: fields become `null`, the site says so). ## Publishing to GitHub Pages @@ -125,7 +127,7 @@ epythet's build-time normalizer fixes the common slips (doctest glued to prose, 1. `pyproject.toml` has `[project] name` and a GitHub URL in `[project.urls]` (the URL drives "GitHub" links and the Pages URL). 2. `epythet quickstart . --ignore tests/` builds without a `ConfigError`. -3. Landing page shows the README; sidebar shows the module tree; `docsrc/_build/html/llms.txt` and `.md` exist. +3. Landing page shows the README; sidebar shows the module tree; `docsrc/_build/html/llms.txt`, `.md` and `build_info.json` exist, and the landing page ends with the `built ... · about this build` line. 4. `epythet validate .` is clean at `--fail-on error`, or its findings are filed. 5. Add the workflow, push, then `epythet check-pages owner/repo`. 6. Add `docsrc/` to `.gitignore` unless it holds hand-written pages. diff --git a/epythet/provenance.py b/epythet/provenance.py new file mode 100644 index 0000000..37135b2 --- /dev/null +++ b/epythet/provenance.py @@ -0,0 +1,1132 @@ +"""Build provenance: which code, which version, which tools produced a site. + +A documentation site is a snapshot. The reader wants to know whether it matches +the repository they are looking at and the package they installed; the +maintainer wants to know whether the latest push has been published yet +(issue #7). This module collects that diagnosis once per build and the rest of +epythet renders it in three places: + +- a one-line footer on the landing page (``built from + () · · about this build``), appended to the + rendered page by :mod:`epythet.sphinx_ext`; +- ``about-this-build.html``, an orphan page (reachable from the footer, absent + from the navigation) with the full diagnosis, rendered from + :data:`ABOUT_PAGE_TEMPLATE` or the project's ``provenance_template``; +- ``build_info.json`` at the site root, the same data for machines, with + stable keys and a ``schema_version``; also listed in ``llms.txt`` and + referenced at the top of the ``.md`` aggregate. + +The ``[tool.epythet] provenance`` key is the seam: ``true`` (default) renders +all three, ``"minimal"`` renders the footer line and the JSON but no page, +``false`` renders nothing. + +Collection never fails a build. No git, no ``git`` binary, no network, a +detached HEAD: every source degrades to ``null`` fields plus an entry in the +``warnings`` list, and the build prints one warning. The record is published, +so nothing local goes into it: remote URLs lose any credentials, path-shaped +remotes are dropped, git's error text is scrubbed of paths, and the reproduce +lines name the clone by its remote, not by the local folder. + +``SOURCE_DATE_EPOCH`` (the reproducible-builds convention Sphinx honours too) +fixes the build time when set. + +>>> from epythet.config import DocsConfig +>>> cfg = DocsConfig(project_dir="/nonexistent", name="pkg", version="1.0") +>>> info = collect_build_info(cfg, check_pypi=False) +>>> info["schema_version"], info["package"]["name"], info["git"]["available"] +(1, 'pkg', False) +>>> "about this build" in render_footer_line(info) +True +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +import threading +from datetime import datetime, timezone +from html import escape +from pathlib import Path +from typing import Any + +#: Bumped when a key is renamed or removed; additions keep the version. +SCHEMA_VERSION = 1 +#: File written at the site root. +BUILD_INFO_FILENAME = "build_info.json" +#: Source file (in docsrc) and document name of the full-diagnosis page. +ABOUT_PAGE_FILENAME = "about-this-build.md" +ABOUT_PAGE_DOCNAME = "about-this-build" +#: Environment variable carrying the collected JSON into the Sphinx process. +BUILD_INFO_ENV = "EPYTHET_BUILD_INFO" +#: Set to ``0`` to skip the PyPI lookup (offline CI, tests). +PYPI_CHECK_ENV = "EPYTHET_PYPI_CHECK" +#: Reproducible-builds convention: seconds since the epoch, fixes ``built_at``. +SOURCE_DATE_EPOCH_ENV = "SOURCE_DATE_EPOCH" +#: Seconds allowed for the PyPI lookup, in total; the build never waits longer. +DEFAULT_PYPI_TIMEOUT = 3.0 +#: Seconds allowed for each git command. +GIT_TIMEOUT = 10 +#: Characters of a commit hash shown in the footer and the summary. +SHORT_COMMIT_LENGTH = 7 +#: First line of the stamp prepended to the ``.md`` aggregate. +AGGREGATE_STAMP_PREFIX = "> built " + +_OFF_VALUES = ("0", "false", "no", "off") +_TIME_FORMAT = "%Y-%m-%dT%H:%M:%SZ" + + +# -------------------------------------------------------------------------- +# Collection +# -------------------------------------------------------------------------- + + +def collect_build_info( + config, + *, + check_pypi: bool | None = None, + pypi_timeout: float = DEFAULT_PYPI_TIMEOUT, + dirty_exclude: tuple[str, ...] | None = None, + environ: dict | None = None, + now: datetime | None = None, +) -> dict[str, Any]: + """The provenance record for one build of ``config``'s project. + + :param config: a :class:`~epythet.config.DocsConfig` + :param check_pypi: query PyPI for the latest release; ``None`` means "unless + the ``EPYTHET_PYPI_CHECK`` environment variable turns it off" + :param pypi_timeout: seconds allowed for that query + :param dirty_exclude: project-relative paths left out of the dirty check, on + top of the docs dir; ``None`` means the directories the ``github`` / + ``gitlab`` targets copy the site into (:data:`epythet.build.COPY_TARGETS`) + :param environ: the environment to read CI variables from (default: ``os.environ``) + :param now: the build time (default: ``SOURCE_DATE_EPOCH`` if set, else now, UTC) + :return: a JSON-serialisable dict; see the module docstring for the keys. + ``site`` counts are ``None`` here and filled in by the Sphinx + extension, which knows what was documented. + """ + environ = os.environ if environ is None else environ + if check_pypi is None: + check_pypi = environ.get(PYPI_CHECK_ENV, "1").strip().lower() not in _OFF_VALUES + if dirty_exclude is None: + from epythet.build import COPY_TARGETS # lazy: build imports this module + + dirty_exclude = tuple(COPY_TARGETS.values()) + now = now or build_time(environ) + warnings: list[str] = [] + git = git_info(config.project_dir, exclude=(config.docs_dir, *dirty_exclude)) + if not git["available"]: + warnings.append(f"git: {git['error']}") + ci = ci_info(environ) + if ci["sha"] and git["available"]: + ci["sha_in_history"] = _is_ancestor(config.project_dir, ci["sha"]) + if git["available"] is False and ci["sha"]: + git = {**git, "commit": ci["sha"], "short_commit": short_commit(ci["sha"])} + if git["branch"] is None and ci["ref_name"]: + git = {**git, "branch": ci["ref_name"]} + if not git["commit_url"] and git["commit"] and ci["repository"]: + server = ci["server_url"] or "https://github.com" + git = { + **git, + "commit_url": f"{server}/{ci['repository']}/commit/{git['commit']}", + } + pypi = ( + pypi_info(config.name, config.version, timeout=pypi_timeout) + if check_pypi + else {"checked": False, "latest": None, "relation": "unknown", "error": None} + ) + if pypi["error"]: + warnings.append(f"pypi: {pypi['error']}") + info: dict[str, Any] = { + "schema_version": SCHEMA_VERSION, + "built_at": now.strftime(_TIME_FORMAT), + "package": { + "name": config.name, + "version": config.version or None, + "display_name": config.display_name, + "source": config_source(config.project_dir), + }, + "git": git, + "ci": ci, + "tools": tool_versions(), + "config": resolved_config(config), + "site": {"modules_documented": None, "objects_documented": None}, + "pypi": pypi, + "reproduce": reproduce_command(config, git), + "warnings": warnings, + } + info["alignment"] = alignment(info) + return info + + +def build_time(environ: dict | None = None) -> datetime: + """Now in UTC, or the instant ``SOURCE_DATE_EPOCH`` names when it is set. + + >>> build_time({"SOURCE_DATE_EPOCH": "0"}).strftime("%Y-%m-%d") + '1970-01-01' + """ + env = os.environ if environ is None else environ + raw = env.get(SOURCE_DATE_EPOCH_ENV, "").strip() + if raw.isdigit(): + return datetime.fromtimestamp(int(raw), timezone.utc) + return datetime.now(timezone.utc) + + +def short_commit(sha: str) -> str: + """The first :data:`SHORT_COMMIT_LENGTH` characters of a commit hash.""" + return sha[:SHORT_COMMIT_LENGTH] + + +def git_info(project_dir: str | Path, *, exclude: tuple[str, ...] = ()) -> dict: + """What git knows about ``project_dir``: commit, branch, tags, dirty flag, remote. + + ``exclude`` names paths (relative to the project) left out of the dirty + check; the build rewrites a committed ``docsrc/``, which must not count. + Everything is ``None`` with ``available`` false when the directory is not a + repository or ``git`` is not installed. A detached HEAD has ``branch`` + ``None``. ``remote_url`` is the publishable form of ``origin`` (no + credentials, no local paths), see :func:`publishable_remote`. + """ + out = { + "available": False, + "commit": None, + "short_commit": None, + "branch": None, + "tags": [], + "dirty": None, + "remote_url": None, + "commit_url": None, + "error": None, + } + commit, error = _git(project_dir, "rev-parse", "HEAD") + if error: + out["error"] = error + return out + out["available"] = True + out["commit"] = commit + out["short_commit"] = short_commit(commit) + branch, _ = _git(project_dir, "rev-parse", "--abbrev-ref", "HEAD") + out["branch"] = branch if branch and branch != "HEAD" else None + tags, _ = _git(project_dir, "tag", "--points-at", "HEAD") + out["tags"] = tags.split() if tags else [] + status, error = _git( + project_dir, + "status", + "--porcelain", + "--untracked-files=no", + "--", + ".", + *(f":(exclude){path.strip('/')}" for path in exclude if path.strip("/")), + ) + out["dirty"] = None if error else bool(status.strip()) + remote, _ = _git(project_dir, "remote", "get-url", "origin") + out["remote_url"] = publishable_remote(remote) if remote else None + repo_url = github_web_url(out["remote_url"]) if out["remote_url"] else None + if repo_url: + out["commit_url"] = f"{repo_url}/commit/{commit}" + return out + + +def _git(project_dir, *args) -> tuple[str, str | None]: + """``(stdout, error)`` of one git command; the error names why it failed.""" + try: + result = subprocess.run( + ["git", "-C", str(project_dir), *args], + capture_output=True, + encoding="utf-8", + errors="replace", + timeout=GIT_TIMEOUT, + ) + except FileNotFoundError: + return "", "git is not installed" + except (OSError, subprocess.TimeoutExpired) as e: + return "", scrub_paths(f"{type(e).__name__}: {e}") + if result.returncode != 0: + message = result.stderr.strip().splitlines() + return "", scrub_paths(message[-1] if message else f"git {args[0]} failed") + return result.stdout.strip(), None + + +def _is_ancestor(project_dir, sha: str) -> bool | None: + """Whether ``sha`` is HEAD or one of its ancestors (``None`` when git cannot say).""" + try: + result = subprocess.run( + ["git", "-C", str(project_dir), "merge-base", "--is-ancestor", sha, "HEAD"], + capture_output=True, + timeout=GIT_TIMEOUT, + ) + except (OSError, subprocess.TimeoutExpired): + return None + # 1: not an ancestor; 128: no such object in this clone. Neither is "in history". + return result.returncode == 0 + + +def scrub_paths(message: str) -> str: + """Replace absolute paths in a diagnostic with ````: the record is published. + + >>> scrub_paths("fatal: detected dubious ownership in repository at '/home/me/x'") + "fatal: detected dubious ownership in repository at ''" + >>> scrub_paths("fatal: not a git repository (or any of the parent directories): .git") + 'fatal: not a git repository (or any of the parent directories): .git' + """ + return re.sub(r"(?:(?<=[\s'\"])|^)(?:/|[A-Za-z]:[\\/])[^\s'\"]*", "", message) + + +def publishable_remote(url: str) -> str | None: + """The form of a remote URL that may appear on a public site, or ``None``. + + Credentials are dropped from scheme URLs, the user part from scp-style + remotes, and path-shaped remotes (a local or ``file://`` clone) are not + published at all. + + >>> publishable_remote("https://me:ghp_secret@github.com/o/r.git") + 'https://github.com/o/r.git' + >>> publishable_remote("thor@myserver.local:repos/demo.git") + 'myserver.local:repos/demo.git' + >>> publishable_remote("git@github.com:o/r.git") + 'git@github.com:o/r.git' + >>> publishable_remote("/Users/me/bare/demo.git") is None + True + >>> publishable_remote("D:/repos/x.git") is None + True + >>> publishable_remote("file:///srv/git/demo.git") is None + True + """ + url = url.strip() + scheme = re.match(r"^([a-z][a-z0-9+.-]*)://", url, re.IGNORECASE) + if scheme: + if scheme.group(1).lower() == "file": + return None + return strip_credentials(url).rstrip("/") + scp = re.match(r"^(?:([^@/:]+)@)?([^/:]+):(.+)$", url) + if scp and len(scp.group(2)) > 1: # a one-letter "host" is a Windows drive + user, host, path = scp.groups() + # ``git@`` is the conventional, anonymous SSH user of the forges: keep it. + return f"{user}@{host}:{path}" if user == "git" else f"{host}:{path}" + return None + + +def strip_credentials(url: str) -> str: + """A scheme URL without any ``user:token@`` part (scp-style remotes pass through). + + >>> strip_credentials("https://me:ghp_secret@github.com/o/r.git") + 'https://github.com/o/r.git' + """ + return re.sub( + r"^([a-z][a-z0-9+.-]*://)[^/@]+@", r"\1", url.strip(), flags=re.IGNORECASE + ) + + +def github_web_url(remote: str) -> str | None: + """The ``https://github.com/owner/repo`` form of a remote URL, or ``None``. + + >>> github_web_url("git@github.com:i2mint/epythet.git") + 'https://github.com/i2mint/epythet' + >>> github_web_url("https://github.com/i2mint/epythet/") + 'https://github.com/i2mint/epythet' + >>> github_web_url("https://gitlab.com/x/y.git") is None + True + """ + match = re.match( + r"^(?:https?://|git@|ssh://git@)github\.com[:/]([^/]+)/([^/]+?)(?:\.git)?/?$", + remote.strip(), + ) + if not match: + return None + owner, repo = match.groups() + return f"https://github.com/{owner}/{repo}" + + +def clone_dirname(remote: str) -> str: + """The directory ``git clone `` creates. + + >>> clone_dirname("https://github.com/org/demo.git"), clone_dirname("git@github.com:o/r") + ('demo', 'r') + """ + tail = remote.rstrip("/").rsplit("/", 1)[-1].rsplit(":", 1)[-1] + return re.sub(r"\.git$", "", tail) + + +def ci_info(environ: dict | None = None) -> dict: + """The GitHub Actions context, when the build runs there (else ``None`` fields). + + ``sha_in_history`` says whether the event's commit is in the built HEAD's + history; the publish action fast-forwards to the branch tip before + building, so HEAD is normally a descendant of ``GITHUB_SHA``, not equal to it. + + >>> ci_info({"GITHUB_ACTIONS": "true", "GITHUB_REPOSITORY": "o/r", + ... "GITHUB_RUN_ID": "42", "GITHUB_SHA": "abc", "GITHUB_REF": "refs/heads/main", + ... "GITHUB_REF_NAME": "main"})["run_url"] + 'https://github.com/o/r/actions/runs/42' + >>> ci_info({})["provider"] is None + True + """ + env = os.environ if environ is None else environ + if env.get("GITHUB_ACTIONS", "").lower() != "true": + return { + "provider": None, + "repository": None, + "sha": None, + "sha_in_history": None, + "ref": None, + "ref_name": None, + "run_id": None, + "run_url": None, + "server_url": None, + } + server = env.get("GITHUB_SERVER_URL") or "https://github.com" + repository = env.get("GITHUB_REPOSITORY") or None + run_id = env.get("GITHUB_RUN_ID") or None + return { + "provider": "github", + "repository": repository, + "sha": env.get("GITHUB_SHA") or None, + "sha_in_history": None, + "ref": env.get("GITHUB_REF") or None, + "ref_name": env.get("GITHUB_REF_NAME") or None, + "run_id": run_id, + "run_url": ( + f"{server}/{repository}/actions/runs/{run_id}" + if repository and run_id + else None + ), + "server_url": server, + } + + +def tool_versions() -> dict: + """Versions of epythet, Sphinx, docutils and Python in the build environment.""" + from importlib.metadata import PackageNotFoundError, version + + def dist_version(name): + try: + return version(name) + except PackageNotFoundError: + return None + + return { + "epythet": dist_version("epythet"), + "sphinx": dist_version("sphinx"), + "docutils": dist_version("docutils"), + "python": ".".join(str(v) for v in sys.version_info[:3]), + } + + +def resolved_config(config) -> dict: + """The documentation choices as the build resolved them (theme, accent, generator...).""" + try: + from epythet.themes import resolve_theme + + theme = resolve_theme( + config.package_name, + theme=config.theme, + accent=config.accent, + mode=config.mode, + theme_options=config.theme_options, + repo_url=config.repo_url, + description=config.description, + docs_dir=config.docs_dir, + ) + html_theme, accent = theme.html_theme, theme.accent_light + except Exception: # an unknown theme name: the build reports that itself + html_theme, accent = config.theme, config.accent or None + try: + api_generator = config.resolved_api_generator + except Exception: + api_generator = config.api_generator + return { + "theme": config.theme, + "html_theme": html_theme, + "accent": accent, + "mode": config.mode, + "api_generator": api_generator, + "ignore": list(config.ignore), + "agent_outputs": config.agent_outputs, + "aggregates": list(config.aggregates), + "ai_artifacts": config.ai_artifacts, + "provenance": config.provenance, + "docs_dir": config.docs_dir, + } + + +def config_source(project_dir: str | Path) -> str | None: + """Which file the package metadata came from: the rule of :mod:`epythet.config`.""" + project_dir = Path(project_dir) + pyproject = project_dir / "pyproject.toml" + if pyproject.is_file(): + try: + import tomllib + + if "project" in tomllib.loads(pyproject.read_text(encoding="utf-8")): + return "pyproject.toml" + except Exception: + pass + if (project_dir / "setup.cfg").is_file(): + return "setup.cfg" + return None + + +def pypi_info( + name: str, version: str, *, timeout: float = DEFAULT_PYPI_TIMEOUT +) -> dict: + """The latest release of ``name`` on PyPI and how ``version`` relates to it. + + ``relation`` is ``same``, ``behind``, ``ahead`` or ``unknown`` (not on + PyPI, unreachable, or unparsable versions). Any failure, including the + ``timeout`` elapsing, is recorded in ``error`` and never raised. + """ + out = {"checked": False, "latest": None, "relation": "unknown", "error": None} + if not name: + return out + try: + latest = _bounded(pypi_latest_version, name, timeout=timeout) + except Exception as e: # network down, DNS, 404, bad JSON: all "not checked" + out["error"] = f"{type(e).__name__}: {e}".strip() + return out + out["checked"] = True + out["latest"] = latest + if latest and version: + out["relation"] = compare_versions(version, latest) + return out + + +def _bounded(function, *args, timeout: float): + """Run ``function`` in a daemon thread; give up after ``timeout`` seconds. + + ``urlopen``'s timeout bounds each socket operation, not name resolution + or the whole transfer; this bounds the wall clock the build spends. + """ + result: dict[str, Any] = {} + + def run(): + try: + result["value"] = function(*args, timeout=timeout) + except Exception as e: # reported by the caller, never raised here + result["error"] = e + + worker = threading.Thread(target=run, daemon=True) + worker.start() + worker.join(timeout) + if worker.is_alive(): + raise TimeoutError(f"no answer within {timeout:g}s") + if "error" in result: + raise result["error"] + if "value" not in result: + raise RuntimeError("the lookup was interrupted") + return result["value"] + + +def pypi_latest_version( + name: str, *, timeout: float = DEFAULT_PYPI_TIMEOUT +) -> str | None: + """The ``info.version`` of ``https://pypi.org/pypi//json`` (``None`` on 404).""" + from urllib.error import HTTPError + from urllib.request import Request, urlopen + + request = Request( + f"https://pypi.org/pypi/{name}/json", + headers={"Accept": "application/json", "User-Agent": "epythet"}, + ) + try: + with urlopen(request, timeout=timeout) as response: + data = json.load(response) + except HTTPError as e: + if e.code == 404: + return None + raise + return data.get("info", {}).get("version") or None + + +def compare_versions(ours: str, latest: str) -> str: + """``same`` / ``behind`` / ``ahead`` of ``latest``, or ``unknown`` when unparsable. + + >>> compare_versions("0.2.4", "0.2.5"), compare_versions("1.0", "1.0.0") + ('behind', 'same') + >>> compare_versions("0.3.0.dev1", "0.2.5"), compare_versions("x", "1") + ('ahead', 'unknown') + """ + try: + from packaging.version import Version + + a, b = Version(ours), Version(latest) + except Exception: + return "unknown" + if a == b: + return "same" + return "behind" if a < b else "ahead" + + +def reproduce_command(config, git: dict) -> str: + """The shell lines that rebuild this site from the same commit.""" + lines = [] + if git.get("remote_url") and git.get("commit"): + remote = git["remote_url"] + lines.append(f"git clone {remote} && cd {clone_dirname(remote)}") + lines.append(f"git checkout {git['commit']}") + epythet_version = tool_versions()["epythet"] + spec = f"epythet=={epythet_version}" if epythet_version else "epythet" + lines.append(f'pip install "{spec}"') + ignore = " ".join(config.ignore) + lines.append(f"epythet quickstart . --ignore {ignore}".rstrip()) + return "\n".join(lines) + + +def alignment(info: dict) -> dict: + """Whether the docs can be trusted to match the repository and the package. + + ``aligned`` is ``True`` when nothing suggests otherwise, ``False`` when a + note says why they may differ, ``None`` when there is no git information to + judge by. ``notes`` are the plain-language reasons, in the order shown. + """ + notes: list[str] = [] + git, pypi, ci, pkg = info["git"], info["pypi"], info["ci"], info["package"] + if git["dirty"]: + notes.append( + f"The working tree had uncommitted changes when the docs were built, " + f"so they may describe code that is not in commit {git['short_commit']}." + ) + if ci["sha"] and git["commit"] and ci.get("sha_in_history") is False: + notes.append( + f"The CI checkout ({short_commit(ci['sha'])}) is not in the history of " + f"the commit the docs were built from ({git['short_commit']})." + ) + if pypi["checked"] and pypi["latest"]: + if pypi["relation"] == "behind": + notes.append( + f"The documented version ({pkg['version']}) is behind the latest " + f"release on PyPI ({pypi['latest']}): `pip install {pkg['name']}` " + "gives newer code than these docs describe." + ) + elif pypi["relation"] == "ahead": + notes.append( + f"The documented version ({pkg['version']}) is ahead of the latest " + f"release on PyPI ({pypi['latest']}): these docs describe unreleased code." + ) + if not git["available"] and not git["commit"]: + notes.append( + "No git information was available, so the commit these docs describe is unknown." + ) + return {"aligned": None, "notes": notes} + return {"aligned": not notes, "notes": notes} + + +# -------------------------------------------------------------------------- +# Rendering: the footer line +# -------------------------------------------------------------------------- + + +def footer_text(info: dict) -> str: + """The provenance line as plain text (no link). + + >>> info = {"built_at": "2026-09-15T14:02:00Z", "package": {"name": "dol", "version": "0.3.1"}, + ... "git": {"short_commit": "a1b2c3d", "branch": "master", "dirty": True}} + >>> footer_text(info) + 'built 2026-09-15 14:02 UTC from a1b2c3d+dirty (master) · dol 0.3.1' + """ + parts = [f"built {human_time(info['built_at'])}"] + git = info["git"] + if git.get("short_commit"): + commit = git["short_commit"] + ("+dirty" if git.get("dirty") else "") + branch = f" ({git['branch']})" if git.get("branch") else "" + parts[0] += f" from {commit}{branch}" + pkg = info["package"] + if pkg.get("version"): + parts.append(f"{pkg['name']} {pkg['version']}") + else: + parts.append(pkg["name"]) + return " · ".join(parts) + + +def render_footer_line( + info: dict, *, about_href: str | None = ABOUT_PAGE_DOCNAME + ".html" +) -> str: + """The landing-page footer as one small HTML paragraph. + + The commit links to GitHub when the remote is known; ``about_href`` is the + "about this build" link target (``None`` to omit the link, as ``minimal`` does + without a page: the JSON is linked instead). The style is inline on purpose: + it must hold in every theme without a stylesheet of its own. + """ + git = info["git"] + built = f"built {escape(human_time(info['built_at']))}" + if git.get("short_commit"): + commit = escape(git["short_commit"]) + ("+dirty" if git.get("dirty") else "") + if git.get("commit_url"): + commit = f'{commit}' + branch = f" ({escape(git['branch'])})" if git.get("branch") else "" + built += f" from {commit}{branch}" + pkg = info["package"] + package = escape(pkg["name"]) + ( + f" {escape(pkg['version'])}" if pkg.get("version") else "" + ) + link = ( + f'about this build' + if about_href + else f'build info' + ) + return ( + '

' + f"{built} · {package} · {link}

" + ) + + +def human_time(iso: str) -> str: + """``2026-09-15T14:02:00Z`` -> ``2026-09-15 14:02 UTC``. + + >>> human_time("2026-09-15T14:02:00Z") + '2026-09-15 14:02 UTC' + """ + try: + return datetime.strptime(iso, _TIME_FORMAT).strftime("%Y-%m-%d %H:%M UTC") + except ValueError: + return iso + + +# -------------------------------------------------------------------------- +# Rendering: the about page +# -------------------------------------------------------------------------- + +#: The Markdown source of the about page. ``{marker}`` must stay: it is how +#: epythet recognises its own file. Literal braces are doubled. Every value +#: is already HTML-escaped (:func:`render_about_page`), so a custom template +#: may place the fields anywhere. +ABOUT_PAGE_TEMPLATE = """\ +--- +orphan: true +--- +{marker} + +# About this build + +{summary} + +{alignment_block} + +## Source + +| | | +|---|---| +| Commit | {commit_cell} | +| Branch | {branch} | +| Tags at this commit | {tags} | +| Working tree | {tree_state} | +| Remote | {remote} | + +## Continuous integration + +{ci_block} + +## Tools + +| | | +|---|---| +| epythet | {epythet_version} | +| Sphinx | {sphinx_version} | +| docutils | {docutils_version} | +| Python | {python_version} | + +## Configuration as resolved + +| | | +|---|---| +| theme | {theme} (Sphinx theme {html_theme}) | +| accent | {accent} | +| api_generator | {api_generator} | +| ignore | {ignore} | +| agent_outputs | {agent_outputs} | +| aggregates | {aggregates} | +| ai_artifacts | {ai_artifacts} | + +## Package on PyPI + +{pypi_block} + +## Reproduce + +```bash +{reproduce} +``` + +The same data, for machines: {build_info_filename} (schema version {schema_version}). +""" + +#: The fields :func:`render_about_page` fills; a custom template may use any subset. +TEMPLATE_FIELDS = frozenset( + { + "marker", + "summary", + "alignment_block", + "commit_cell", + "branch", + "tags", + "tree_state", + "remote", + "ci_block", + "epythet_version", + "sphinx_version", + "docutils_version", + "python_version", + "theme", + "html_theme", + "accent", + "api_generator", + "ignore", + "agent_outputs", + "aggregates", + "ai_artifacts", + "pypi_block", + "reproduce", + "build_info_filename", + "schema_version", + } +) + + +def render_about_page(info: dict, *, template: str = ABOUT_PAGE_TEMPLATE) -> str: + """The Markdown source of ``about-this-build.md`` for a collected ``info``. + + Values from the repository (branch, tags, remote, versions) are rendered + as escaped inline HTML, never as Markdown: a ref name is user input. + """ + from epythet.templates import INDEX_MARKER + + git, pkg, ci, pypi, cfg, tools = ( + info["git"], + info["package"], + info["ci"], + info["pypi"], + info["config"], + info["tools"], + ) + unknown = "unknown" + fields = { + "marker": INDEX_MARKER, + "summary": _summary(info), + "alignment_block": _alignment_block(info), + "commit_cell": ( + _link(git["commit_url"], _code(git["commit"])) + if git.get("commit_url") + else (_code(git["commit"]) if git.get("commit") else unknown) + ), + "branch": _code(git["branch"]) if git.get("branch") else "none (detached HEAD)", + "tags": ", ".join(_code(t) for t in git.get("tags") or []) or "none", + "tree_state": ( + "dirty (uncommitted changes)" + if git.get("dirty") + else ("clean" if git.get("dirty") is False else unknown) + ), + "remote": _code(git["remote_url"]) if git.get("remote_url") else unknown, + "ci_block": _ci_block(ci), + "epythet_version": _text(tools.get("epythet")) or unknown, + "sphinx_version": _text(tools.get("sphinx")) or unknown, + "docutils_version": _text(tools.get("docutils")) or unknown, + "python_version": _text(tools.get("python")) or unknown, + "theme": _code(cfg["theme"]), + "html_theme": _code(cfg["html_theme"]), + "accent": _code(cfg["accent"]) if cfg.get("accent") else unknown, + "api_generator": _code(cfg["api_generator"]), + "ignore": ", ".join(_code(p) for p in cfg["ignore"]) or "none", + "agent_outputs": _code(str(cfg["agent_outputs"]).lower()), + "aggregates": ", ".join(_code(a) for a in cfg["aggregates"]) or "none", + "ai_artifacts": _code(str(cfg["ai_artifacts"]).lower()), + "pypi_block": _pypi_block(pkg, pypi), + "reproduce": info["reproduce"].replace("```", "` ` `"), + "build_info_filename": BUILD_INFO_FILENAME, + "schema_version": info["schema_version"], + } + return template.format(**fields) + + +#: Characters that keep their meaning inside inline HTML in a Markdown table +#: cell or code span (a ``|`` splits the row, a backtick opens a code span). +_MARKDOWN_ACTIVE = { + "|": "|", + "`": "`", + "*": "*", + "_": "_", + "[": "[", +} + + +def _text(value) -> str: + """HTML-escaped, Markdown-inert text for a value from the repository. + + >>> _text("t|pipe"), _text("x``") + ('t|pipe', 'x`<b>`') + """ + if value is None: + return "" + text = escape(str(value)) + for char, entity in _MARKDOWN_ACTIVE.items(): + text = text.replace(char, entity) + return text + + +def _code(value) -> str: + return f"{_text(value)}" + + +def _link(href: str, label_html: str) -> str: + return f'{label_html}' + + +def _summary(info: dict) -> str: + git, pkg = info["git"], info["package"] + when = human_time(info["built_at"]) + version = f" {_text(pkg['version'])}" if pkg.get("version") else "" + source = f" (from {_code(pkg['source'])})" if pkg.get("source") else "" + if git.get("short_commit"): + commit = ( + _link(git["commit_url"], _code(git["short_commit"])) + if git.get("commit_url") + else _code(git["short_commit"]) + ) + branch = f" on branch {_code(git['branch'])}" if git.get("branch") else "" + where = f" from commit {commit}{branch}" + else: + where = "" + return ( + f"This documentation was built on **{when}**{where}, for " + f"**{_text(pkg['name'])}{version}**{source}." + ) + + +def _alignment_block(info: dict) -> str: + align = info["alignment"] + if align["aligned"]: + return ( + ":::{note}\nNothing suggests a mismatch: the tree was clean at the commit " + "above, and the documented version is the one on PyPI" + + ("." if info["pypi"]["checked"] else " (PyPI was not checked).") + + "\n:::" + ) + notes = "\n".join(f"- {escape(note, quote=False)}" for note in align["notes"]) + return ( + ":::{warning}\nThe documentation and the package may be misaligned:\n\n" + f"{notes}\n:::" + ) + + +def _ci_block(ci: dict) -> str: + if not ci.get("provider"): + return "Not built in CI (no GitHub Actions environment was detected)." + run = _link(ci["run_url"], _text(ci["run_id"])) if ci.get("run_url") else "unknown" + sha = _code(ci["sha"]) if ci.get("sha") else "unknown" + if ci.get("sha_in_history") is True: + sha += " (in the history of the built commit)" + repository = _code(ci["repository"]) if ci.get("repository") else "unknown" + ref = _code(ci["ref"]) if ci.get("ref") else "unknown" + return ( + "| | |\n|---|---|\n" + f"| Repository | {repository} |\n" + f"| Run | {run} |\n" + f"| Ref | {ref} |\n" + f"| Event commit | {sha} |" + ) + + +def _pypi_block(pkg: dict, pypi: dict) -> str: + if not pypi["checked"]: + error = f" ({_text(pypi['error'])})" if pypi.get("error") else "" + return f"Not checked{error}." + if not pypi["latest"]: + return f"{_code(pkg['name'])} is not on PyPI." + version = _text(pkg["version"]) + relation = { + "same": "the same as the documented version.", + "behind": f"newer than the documented version ({version}).", + "ahead": f"older than the documented version ({version}).", + "unknown": "not comparable with the documented version.", + }[pypi["relation"]] + url = f"https://pypi.org/project/{pkg['name']}/{pypi['latest']}/" + return f"Latest release: {_link(url, _text(pypi['latest']))}, {relation}" + + +# -------------------------------------------------------------------------- +# Site-level helpers used by build.py and sphinx_ext.py +# -------------------------------------------------------------------------- + + +def about_page(info: dict, *, template: str = ABOUT_PAGE_TEMPLATE): + """The about page as a :class:`~epythet.scaffold.PageSpec`. + + :raises ConfigError: when ``template`` names a field the renderer does not + provide (literal braces must be doubled: ``{{``). + """ + from epythet.config import ConfigError + from epythet.scaffold import PageSpec + + try: + content = render_about_page(info, template=template) + except Exception as e: # a wrong field, attribute or format spec + raise ConfigError( + f"provenance_template: unknown field {e}; the fields are " + f"{sorted(TEMPLATE_FIELDS)} and literal braces must be doubled ({{{{ and }}}})" + ) from e + return PageSpec(ABOUT_PAGE_FILENAME, content) + + +def about_template(config) -> str: + """The about page's template: ``[tool.epythet] provenance_template`` or the default. + + The key names a file relative to the project root, with the same contract + as ``ai_artifacts_template``; the epythet marker is prepended when absent. + + :raises ConfigError: when the file does not exist + """ + from epythet.config import ConfigError + from epythet.templates import INDEX_MARKER + + template_path = getattr(config, "provenance_template", "") + if not template_path: + return ABOUT_PAGE_TEMPLATE + path = config.project_dir / template_path + if not path.is_file(): + raise ConfigError( + f"[tool.epythet] provenance_template points at {path}, which does not exist" + ) + return with_front_matter_and_marker(path.read_text(encoding="utf-8")) + + +def with_front_matter_and_marker(template: str) -> str: + """Make a page template an orphan (out of the toctree) that carries the marker. + + YAML front matter must be the very first thing in the file, so the marker + goes after it; ``orphan: true`` is added when the front matter lacks it, + and front matter is created when there is none. + + >>> print(with_front_matter_and_marker("# Build\\n")) + --- + orphan: true + --- + {marker} + + # Build + + >>> print(with_front_matter_and_marker("---\\ntitle: x\\n---\\n{marker}\\n# B\\n")) + --- + title: x + orphan: true + --- + {marker} + # B + + """ + from epythet.templates import INDEX_MARKER + + has_marker = INDEX_MARKER in template or "{marker}" in template + match = re.match(r"^---\r?\n(.*?)\r?\n---\r?\n", template, re.DOTALL) + if match: + front, body = match.group(1), template[match.end() :] + if not re.search(r"^orphan\s*:", front, re.MULTILINE): + front += "\norphan: true" + else: + front, body = "orphan: true", template + if not has_marker: + body = "{marker}\n\n" + body + return f"---\n{front}\n---\n{body}" + + +def site_counts(env) -> dict: + """Documented-module and documented-object counts from a Sphinx environment.""" + from epythet.confgen import API_ROOT + + docnames = getattr(env, "found_docs", ()) or () + modules = sum( + 1 + for d in docnames + if (d.startswith("_autosummary/") or d.startswith(f"{API_ROOT}/")) + and d != f"{API_ROOT}/index" + ) + try: + objects = sum( + 1 + for entry in env.get_domain("py").objects.values() + if entry.objtype != "module" + ) + except Exception: + objects = None + return {"modules_documented": modules, "objects_documented": objects} + + +def write_build_info(html_dir: str | Path, info: dict) -> Path: + """Write ``build_info.json`` at the site root; returns its path.""" + target = Path(html_dir) / BUILD_INFO_FILENAME + target.write_text( + json.dumps(info, indent=2, sort_keys=False) + "\n", encoding="utf-8" + ) + return target + + +def reference_from_agent_outputs( + html_dir: str | Path, info: dict, *, package_name: str +) -> None: + """List ``build_info.json`` in ``llms.txt`` and stamp the top of ``.md``.""" + html_dir = Path(html_dir) + llms = html_dir / "llms.txt" + line = ( + f"- [{BUILD_INFO_FILENAME}]({BUILD_INFO_FILENAME}): build provenance " + "(commit, version, tool versions, whether the docs match the package)" + ) + if llms.is_file(): + text = llms.read_text(encoding="utf-8") + if BUILD_INFO_FILENAME not in text: + llms.write_text( + text.rstrip("\n") + "\n\n## Build\n\n" + line + "\n", encoding="utf-8" + ) + aggregate = html_dir / f"{package_name}.md" + if aggregate.is_file(): + text = aggregate.read_text(encoding="utf-8") + if not text.startswith(AGGREGATE_STAMP_PREFIX): + stamp = f"> {footer_text(info)}. Details: {BUILD_INFO_FILENAME}\n\n" + aggregate.write_text(stamp + text, encoding="utf-8") + + +def prune_site(html_dir: str | Path, *, keep_page: bool, keep_json: bool) -> list: + """Remove provenance outputs a previous build left in ``html_dir``. + + Sphinx never cleans its output directory, so a project that turned + ``provenance`` off (or down to ``"minimal"``) would otherwise keep + publishing a stale page or JSON. Returns the paths removed. + """ + html_dir = Path(html_dir) + stale = [] + if not keep_page: + stale += [ + html_dir / f"{ABOUT_PAGE_DOCNAME}.html", + html_dir / f"{ABOUT_PAGE_DOCNAME}.html.md", + html_dir / ABOUT_PAGE_DOCNAME / "index.html", # dirhtml + ] + if not keep_json: + stale.append(html_dir / BUILD_INFO_FILENAME) + removed = [] + for path in stale: + if path.is_file(): + path.unlink() + removed.append(path) + return removed + + +def load_build_info(raw: str | None) -> dict | None: + """Parse the JSON the build process hands over in ``EPYTHET_BUILD_INFO``. + + Anything that is not a record of this module's schema is ignored, so a + stale or foreign value in the environment never breaks a build. + + >>> load_build_info('{"schema_version": 1, "git": {}}')["schema_version"] + 1 + >>> load_build_info('"str"') is None and load_build_info("{") is None + True + """ + if not raw: + return None + try: + info = json.loads(raw) + except ValueError: + return None + if not isinstance(info, dict) or info.get("schema_version") != SCHEMA_VERSION: + return None + return info diff --git a/epythet/scaffold.py b/epythet/scaffold.py index 945d339..a9f161d 100644 --- a/epythet/scaffold.py +++ b/epythet/scaffold.py @@ -95,7 +95,7 @@ def scaffold( name != "api" or config.resolved_api_generator == "autoapi" ): shutil.rmtree(docsrc / name) - _write_if_generated( + write_generated_file( docsrc / "conf.py", templates.conf_py_shim, markers=(templates.CONF_SHIM_MARKER, templates.LEGACY_CONF_MARKER), @@ -104,14 +104,14 @@ def scaffold( if (docsrc / "index.rst").is_file(): say("Keeping hand-written index.rst (no index.md written)") else: - _write_if_generated( + write_generated_file( docsrc / "index.md", render_index_md(config, pages=pages), markers=(templates.INDEX_MARKER,), say=say, ) for page in pages: - _write_if_generated( + write_generated_file( docsrc / page.filename, page.content, markers=(page.marker,), say=say ) _remove_stale_generated_pages(docsrc, pages, say) @@ -126,13 +126,39 @@ def scaffold( render_autosummary_module_template(config.api_ignore), encoding="utf-8" ) gitignore = docsrc / ".gitignore" - if ( - not gitignore.exists() - or gitignore.read_text(encoding="utf-8").strip() - in templates.LEGACY_DOCSRC_GITIGNORES + refresh_docsrc_gitignore(docsrc / ".gitignore") + return docsrc + + +def refresh_docsrc_gitignore(gitignore: Path) -> None: + """Bring ``docsrc/.gitignore`` up to date without losing anyone's lines. + + A missing file, a 0.1.x file or an earlier generated version is replaced; + a generated file the user appended to gets the missing generated entries + appended; a hand-written file is left alone. + """ + if not gitignore.exists(): + gitignore.write_text(templates.docsrc_gitignore, encoding="utf-8") + return + text = gitignore.read_text(encoding="utf-8") + if text == templates.docsrc_gitignore: + return + if text.strip() in templates.LEGACY_DOCSRC_GITIGNORES or text.strip() in ( + t.strip() for t in templates.PREVIOUS_DOCSRC_GITIGNORES ): gitignore.write_text(templates.docsrc_gitignore, encoding="utf-8") - return docsrc + return + if text.startswith(templates.DOCSRC_GITIGNORE_HEADER): + present = {line.strip() for line in text.splitlines()} + missing = [ + line + for line in templates.docsrc_gitignore.splitlines() + if line.strip() and line.strip() not in present + ] + if missing: + gitignore.write_text( + text.rstrip("\n") + "\n" + "\n".join(missing) + "\n", encoding="utf-8" + ) def render_autosummary_module_template(ignore: Sequence[str]) -> str: @@ -264,7 +290,13 @@ def _remove_stale_generated_pages(docsrc: Path, pages, say) -> None: say(f"Removed stale generated {filename}") -def _write_if_generated(path: Path, content: str, *, markers, say) -> None: +def write_generated_file(path: Path, content: str, *, markers, say=None) -> None: + """Write a generated file unless a hand-written one (no marker) is in the way. + + :param markers: strings, any of which identifies an epythet-generated file + :param say: a ``print``-like reporter (``None`` for silence) + """ + say = say or (lambda *a, **k: None) if path.exists(): existing = path.read_text(encoding="utf-8", errors="replace") if not any(marker in existing for marker in markers): @@ -274,3 +306,15 @@ def _write_if_generated(path: Path, content: str, *, markers, say) -> None: return path.write_text(content, encoding="utf-8") say(f"Wrote {path}") + + +def remove_generated_file(path: Path, *, markers, say=None) -> bool: + """Delete ``path`` if it is a file epythet generated (carries a marker).""" + if not path.is_file(): + return False + existing = path.read_text(encoding="utf-8", errors="replace") + if not any(marker in existing for marker in markers): + return False + path.unlink() + (say or (lambda *a, **k: None))(f"Removed generated {path.name}") + return True diff --git a/epythet/sphinx_conf.py b/epythet/sphinx_conf.py index e70f369..41ae961 100644 --- a/epythet/sphinx_conf.py +++ b/epythet/sphinx_conf.py @@ -22,6 +22,9 @@ from epythet.confgen import sphinx_settings as _sphinx_settings from epythet.config import ConfigError as _ConfigError from epythet.config import load_config as _load_config +from epythet.provenance import BUILD_INFO_ENV as _BUILD_INFO_ENV +from epythet.provenance import collect_build_info as _collect_build_info +from epythet.provenance import load_build_info as _load_build_info #: Environment variable naming the project root (set by ``epythet make``). PROJECT_DIR_ENV = "EPYTHET_PROJECT_DIR" @@ -56,4 +59,26 @@ def _overrides() -> dict: if _path.is_dir() and str(_path) not in _sys.path: _sys.path.insert(0, str(_path)) -globals().update(_sphinx_settings(epythet_config)) + +def _build_info(): + """The provenance record: handed over by ``epythet make``, else collected here. + + Running ``sphinx-build`` directly still gets the footer and the JSON; the + about page needs :func:`epythet.build.build`, which writes its source. + This path skips the PyPI lookup: non-html builders (``doctest``, + ``markdown``, ``validate``'s render pass) go through here too and render + nothing from it. + """ + if not epythet_config.provenance: + return None + info = _load_build_info(_os.environ.get(_BUILD_INFO_ENV)) + if info is not None: + return info + try: + return _collect_build_info(epythet_config, check_pypi=False) + except Exception as e: # provenance never fails a build + print(f"epythet: build provenance unavailable ({e})", file=_sys.stderr) + return None + + +globals().update(_sphinx_settings(epythet_config, build_info=_build_info())) diff --git a/epythet/sphinx_ext.py b/epythet/sphinx_ext.py index 5bba05c..c6df2b9 100644 --- a/epythet/sphinx_ext.py +++ b/epythet/sphinx_ext.py @@ -10,7 +10,10 @@ relations on every HTML page when ``epythet_agent_outputs`` is on, see :mod:`epythet.agent_outputs`; - the theme accent stylesheet ``_static/epythet.css`` when the chosen theme - takes its colours from CSS variables (``epythet_theme_css``). + takes its colours from CSS variables (``epythet_theme_css``); +- the build provenance (``epythet_build_info``): the one-line footer appended + to the landing page, the documented-module counts on the about page, and + ``build_info.json`` at the site root, see :mod:`epythet.provenance`. """ from __future__ import annotations @@ -20,6 +23,12 @@ from epythet.agent_outputs import inject_link_relations from epythet.normalizer import sphinx_process_docstring +from epythet.provenance import ( + ABOUT_PAGE_DOCNAME, + render_footer_line, + site_counts, + write_build_info, +) CSS_FILENAME = "epythet.css" @@ -46,6 +55,67 @@ def _link_relations_if_enabled(app, pagename, templatename, context, doctree): inject_link_relations(app, pagename, templatename, context, doctree) +def _is_html_build(app) -> bool: + return getattr(app.builder, "format", "") == "html" + + +def _provenance_page_context(app, pagename, templatename, context, doctree): + """Append the footer line to the landing page and the counts to the about page.""" + info = getattr(app.config, "epythet_build_info", None) + if not info or not _is_html_build(app) or "body" not in context: + return + try: + if pagename == app.config.root_doc: + has_page = ABOUT_PAGE_DOCNAME in app.env.found_docs + about_href = ( + app.builder.get_relative_uri(pagename, ABOUT_PAGE_DOCNAME) + if has_page + else None + ) + context["body"] += "\n" + render_footer_line(info, about_href=about_href) + elif pagename == ABOUT_PAGE_DOCNAME: + block = _site_counts_html(site_counts(app.env)) + anchor = '
' + if anchor in context["body"]: + context["body"] = context["body"].replace( + anchor, block + "\n" + anchor, 1 + ) + else: + context["body"] += "\n" + block + except Exception as e: # provenance never fails a build + logging.getLogger(__name__).warning( + "epythet: provenance not rendered on %s (%s)", pagename, e + ) + + +def _site_counts_html(counts: dict) -> str: + rows = "".join( + f"

{label}

{value if value is not None else 'unknown'}

" + for label, value in ( + ("Modules documented", counts.get("modules_documented")), + ("Objects documented", counts.get("objects_documented")), + ) + ) + return ( + '

Site

' + f'{rows}
' + ) + + +def _write_build_info(app, exception) -> None: + """``build-finished`` hook: ``build_info.json`` with the counts filled in.""" + info = getattr(app.config, "epythet_build_info", None) + if exception is not None or not info or not _is_html_build(app): + return + try: + info = dict(info, site=site_counts(app.env)) + write_build_info(app.outdir, info) + except Exception as e: # provenance never fails a build + logging.getLogger(__name__).warning( + "epythet: build_info.json not written (%s)", e + ) + + def _version() -> str: from importlib.metadata import PackageNotFoundError, version @@ -68,9 +138,13 @@ def setup(app): app.add_config_value("epythet_agent_outputs", True, "html") # rebuild "": rule lists hold functions or dotted paths; never pickled into the env app.add_config_value("epythet_normalizer_rules", None, "") + app.add_config_value("epythet_provenance", True, "html", types=(bool, str)) + app.add_config_value("epythet_build_info", None, "html", types=(dict,)) app.connect("autodoc-process-docstring", sphinx_process_docstring, priority=400) app.connect("builder-inited", write_theme_css) app.connect("html-page-context", _link_relations_if_enabled) + app.connect("html-page-context", _provenance_page_context) + app.connect("build-finished", _write_build_info) logging.getLogger("sphinx.sphinx.ext.autosummary").addFilter( _DropIgnoredModuleWarnings() ) diff --git a/epythet/templates.py b/epythet/templates.py index 2f3fe3a..fa0ce23 100644 --- a/epythet/templates.py +++ b/epythet/templates.py @@ -2,7 +2,8 @@ Only two files are generated: the ``conf.py`` shim and the ``index.md`` landing page. Everything else (the API tree, the agent twins) is produced by Sphinx -extensions at build time. +extensions at build time. The about-this-build page's template lives with its +data in :mod:`epythet.provenance`. """ #: Marker line present in every conf.py epythet generated (v2), used to decide @@ -158,11 +159,29 @@ {{%- endblock %}} """ -docsrc_gitignore = """\ -# Generated by epythet at build time +#: First line of the generated docsrc/.gitignore; a file starting with it is +#: epythet's own: an exact earlier version is replaced, an edited one is +#: appended to (:func:`epythet.scaffold.refresh_docsrc_gitignore`). +DOCSRC_GITIGNORE_HEADER = "# Generated by epythet at build time" + +#: Earlier generated versions, replaced wholesale when found verbatim. +PREVIOUS_DOCSRC_GITIGNORES = ( + f"""\ +{DOCSRC_GITIGNORE_HEADER} +_build/ +api/ +_autosummary/ +_templates/ +_static/epythet.css +""", # 0.2.0 - 0.2.6 +) + +docsrc_gitignore = f"""\ +{DOCSRC_GITIGNORE_HEADER} _build/ api/ _autosummary/ _templates/ _static/epythet.css +about-this-build.md """ diff --git a/tests/conftest.py b/tests/conftest.py index 26fc146..64e2f18 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,11 +1,40 @@ """Shared fixtures: a throwaway project builder and an isolated user data dir.""" +import os import textwrap from pathlib import Path import pytest +#: 2026-09-15T12:00:00Z; every build in the suite claims this time. +FIXED_BUILD_EPOCH = "1789473600" + + +@pytest.fixture(autouse=True, scope="session") +def _hermetic_provenance(tmp_path_factory): + """Provenance in the test suite: no network, a fixed build time, isolated git. + + ``EPYTHET_PYPI_CHECK=0`` keeps PyPI out (offline CI, determinism); + ``SOURCE_DATE_EPOCH`` makes rebuilds byte-identical; the git variables keep + the developer's global config (``commit.gpgsign``, hooks, templates) out of + the fixture repositories; ``GITHUB_ACTIONS`` is unset so the runner's own + context never reaches a record. Session-scoped so it is in place before the + module-scoped smoke builds. + """ + with pytest.MonkeyPatch.context() as mp: + mp.setenv("EPYTHET_PYPI_CHECK", "0") + mp.setenv("SOURCE_DATE_EPOCH", FIXED_BUILD_EPOCH) + mp.setenv("GIT_CONFIG_GLOBAL", os.devnull) + mp.setenv("GIT_CONFIG_NOSYSTEM", "1") + # A fixture project that is not a repo must not find one above the temp dir. + mp.setenv("GIT_CEILING_DIRECTORIES", str(tmp_path_factory.getbasetemp())) + # The suite itself runs in GitHub Actions: its context must not leak into + # the records the tests build (tests that want CI pass an explicit environ). + mp.delenv("GITHUB_ACTIONS", raising=False) + yield + + @pytest.fixture def make_project(tmp_path): """``make_project(name, {"mod.py": source, ...})`` -> project root with a pyproject.""" diff --git a/tests/test_build.py b/tests/test_build.py index f3c3b7d..1713d77 100644 --- a/tests/test_build.py +++ b/tests/test_build.py @@ -7,7 +7,10 @@ (HTML, then the Markdown pass for the twins), so it takes a few seconds. """ +import json import shutil +import subprocess +import sys from pathlib import Path import pytest @@ -125,9 +128,30 @@ def project(tmp_path_factory) -> Path: (pkg / "tests").mkdir() (pkg / "tests" / "__init__.py").write_text("") (pkg / "tests" / "test_x.py").write_text(TEST_MODULE) + _git_init(root, remote="https://github.com/org/demo.git", tag="v0.1.0") + if PIPE_TAG_ALLOWED: # a legal ref name, except on Windows + subprocess.run(["git", "tag", PIPE_TAG], cwd=root, check=True) + (pkg / "core.py").write_text(CORE + "\n# uncommitted change: the tree is dirty\n") return root +#: A tag with a Markdown table delimiter; Windows git refuses ``|`` in ref names. +PIPE_TAG = "t|pipe" +PIPE_TAG_ALLOWED = sys.platform != "win32" + + +def _git_init(root: Path, *, remote: str, tag: str) -> None: + git = ["git", "-c", "user.email=jane@example.com", "-c", "user.name=Jane"] + for args in ( + ["init", "-q", "-b", "main"], + ["add", "-A"], + ["commit", "-qm", "init"], + ["remote", "add", "origin", remote], + ["tag", tag], + ): + subprocess.run([*git, *args], cwd=root, check=True, capture_output=True) + + @pytest.fixture(scope="module") def site(project) -> Path: html = quickstart(project, ignore=["tests/"]) @@ -200,6 +224,164 @@ def test_agent_outputs_and_aggregate(site): assert "demo.md" in (site / "llms.txt").read_text() +def test_provenance_footer_page_and_json(project, site): + """WP7: the landing line, the orphan about page and build_info.json agree.""" + info = json.loads((site / "build_info.json").read_text(encoding="utf-8")) + git = info["git"] + assert info["schema_version"] == 1 and info["package"]["version"] == "0.1.0" + assert ( + git["branch"] == "main" + and git["tags"] == ([PIPE_TAG] if PIPE_TAG_ALLOWED else []) + ["v0.1.0"] + and git["dirty"] is True + ) + assert git["commit_url"] == f"https://github.com/org/demo/commit/{git['commit']}" + assert info["site"]["modules_documented"] >= 4 + assert info["site"]["objects_documented"] >= 2 + assert info["pypi"]["checked"] is False # conftest turns the lookup off + assert info["built_at"] == "2026-09-15T12:00:00Z" # SOURCE_DATE_EPOCH from conftest + assert ( + info["alignment"]["aligned"] is False + and "uncommitted" in info["alignment"]["notes"][0] + ) + + index = (site / "index.html").read_text(encoding="utf-8") + assert index.count('class="epythet-provenance"') == 1 + assert f">{git['short_commit']}+dirty (main) · demo 0.1.0 · " in index + assert 'about this build' in index + assert 'class="epythet-provenance"' not in ( + site / "_autosummary" / "demo.core.html" + ).read_text(encoding="utf-8") + + about = (site / "about-this-build.html").read_text(encoding="utf-8") + assert "may be misaligned" in about and git["commit"] in about + # A "|" in a ref name must not split the table row (rendered HTML, not the source). + row = about[about.index("Tags at this commit") :] + row = row[: row.index("")] + assert row.count("{PIPE_TAG}" in row + assert "Modules documented" in about and about.index( + "Modules documented" + ) < about.index('id="reproduce"') + assert ( + "about-this-build" not in index.split('class="epythet-provenance"')[0] + ) # not in the nav + assert (site / "about-this-build.html.md").is_file() + assert "build_info.json" in (site / "llms.txt").read_text(encoding="utf-8") + assert (site / "demo.md").read_text(encoding="utf-8").startswith("> built ") + assert "about-this-build.md" in (project / "docsrc" / ".gitignore").read_text( + encoding="utf-8" + ) + + +def test_provenance_off_and_minimal(project): + other = project.parent / "noprov" + shutil.copytree(project, other, ignore=shutil.ignore_patterns("docsrc")) + quickstart(other) # provenance on: the page source exists + assert (other / "docsrc" / "about-this-build.md").is_file() + docs = build(load_config(other), "github") + assert (docs / "about-this-build.html").is_file() + + (other / "pyproject.toml").write_text( + PYPROJECT + "[tool.epythet]\nprovenance = false\n" + ) + html = quickstart(other) + assert not (other / "docsrc" / "about-this-build.md").exists() # no stale page + assert not (html / "build_info.json").exists() + assert not (html / "about-this-build.html").exists() + docs = build(load_config(other), "github") + assert not (docs / "about-this-build.html").exists() # copy target pruned too + assert not (docs / "build_info.json").exists() + assert not (html / "about-this-build.html").exists() + assert "epythet-provenance" not in (html / "index.html").read_text() + + (other / "pyproject.toml").write_text( + PYPROJECT + '[tool.epythet]\nprovenance = "minimal"\n' + ) + html = quickstart(other) + assert (html / "build_info.json").is_file() + assert not (other / "docsrc" / "about-this-build.md").exists() + assert not (html / "about-this-build.html").exists() + index = (html / "index.html").read_text() + assert ( + "epythet-provenance" in index + and 'build info' in index + ) + + +def test_provenance_template_override(project): + other = project.parent / "provtemplate" + shutil.copytree(project, other, ignore=shutil.ignore_patterns("docsrc")) + (other / "misc" / "about.md").write_text( + "# Build\n\n{summary}\n\nCommit: {commit_cell}\n" + ) + (other / "pyproject.toml").write_text( + PYPROJECT + '[tool.epythet]\nprovenance_template = "misc/about.md"\n' + ) + html = quickstart(other) + about = (html / "about-this-build.html").read_text() + assert "

Build" in about and "demo 0.1.0" in about and "Reproduce" not in about + # Front matter in the template stays front matter (the marker goes after it). + (other / "misc" / "about.md").write_text( + "---\ntitle: Custom\n---\n# Build\n\n{summary}\n" + ) + about = (quickstart(other) / "about-this-build.html").read_text() + assert "

Build" in about and "title: Custom" not in about + assert "orphan" not in about + (other / "misc" / "about.md").write_text("{no_such_field}\n") + with pytest.raises(ConfigError, match="no_such_field"): + quickstart(other) + (other / "misc" / "about.md").write_text("{summary.foo}\n") + with pytest.raises(ConfigError, match="foo"): + quickstart(other) + + +def test_hand_written_about_page_survives_provenance_off(project): + other = project.parent / "handabout" + shutil.copytree(project, other, ignore=shutil.ignore_patterns("docsrc")) + (other / "pyproject.toml").write_text( + PYPROJECT + "[tool.epythet]\nprovenance = false\n" + ) + (other / "docsrc").mkdir() + (other / "docsrc" / "about-this-build.md").write_text( + "---\norphan: true\n---\n# Our build notes\n" + ) + html = quickstart(other) + assert "Our build notes" in (html / "about-this-build.html").read_text() + + +def test_dirhtml_carries_provenance_with_the_right_link(project): + out = build(load_config(project), "dirhtml") + assert (out / "about-this-build" / "index.html").is_file() + assert (out / "build_info.json").is_file() + assert ( + 'href="about-this-build/">about this build' + in (out / "index.html").read_text() + ) + + +def test_docsrc_gitignore_refresh(make_project): + from epythet.scaffold import refresh_docsrc_gitignore + from epythet.templates import ( + DOCSRC_GITIGNORE_HEADER, + PREVIOUS_DOCSRC_GITIGNORES, + docsrc_gitignore, + ) + + project = make_project("gi", {}) + target = project / ".gitignore" + target.write_text(PREVIOUS_DOCSRC_GITIGNORES[0]) + refresh_docsrc_gitignore(target) + assert target.read_text() == docsrc_gitignore # an earlier version: replaced + target.write_text(DOCSRC_GITIGNORE_HEADER + "\n_build/\n\n# mine\nscratch/\n") + refresh_docsrc_gitignore(target) + text = target.read_text() + assert "scratch/" in text and "about-this-build.md" in text # edited: appended to + target.write_text("_build/\nmine/\n") + refresh_docsrc_gitignore(target) + assert target.read_text() == "_build/\nmine/\n" # hand-written: kept + + def test_theme_is_deterministic_and_rebuild_is_stable(project, site): first = (site / "index.html").read_text() make(project, "html") @@ -310,7 +492,9 @@ def all_project(tmp_path_factory) -> Path: (pkg / "core.py").write_text('"""Core."""\n\n\ndef thing():\n """A thing."""\n') (pkg / "extra.py").write_text('"""Not in __all__, still a public module."""\n') (pkg / "_private.py").write_text('"""Private: never a page."""\n') - (pkg / "_listed.py").write_text('"""Private but named in __all__: keeps its page."""\n') + (pkg / "_listed.py").write_text( + '"""Private but named in __all__: keeps its page."""\n' + ) (pkg / "__main__.py").write_text(ARGPARSE_MAIN) (pkg / "tests").mkdir() (pkg / "tests" / "__init__.py").write_text("") @@ -327,7 +511,10 @@ def all_site(all_project) -> Path: def test_comma_joined_ignore_is_split(all_project): - assert load_config(all_project, ignore=["tests/,scrap/"]).ignore == ("tests/", "scrap/") + assert load_config(all_project, ignore=["tests/,scrap/"]).ignore == ( + "tests/", + "scrap/", + ) def test_all_of_objects_still_yields_every_public_submodule(all_site): diff --git a/tests/test_cli.py b/tests/test_cli.py index 838a9d0..6320c38 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -13,7 +13,7 @@ v2 (0.2.0) deliberately changed two things, and the goldens were updated with that decision: ``make-docsrc`` gained ``--ignore``, and ``quickstart`` now calls one orchestrator (``load_config`` -> ``scaffold`` -> ``build``) instead of -the three legacy functions. +the three legacy functions. WP7 (0.2.6) added ``build-info``. """ import subprocess @@ -62,7 +62,7 @@ def _usage(argv): (): ( "usage: epythet [-h] " "{make-docsrc,make-autodocs,make,quickstart,check-pages,configure-pages,validate," - "ai-artifacts,ai-readme-check,repair,migrate-style,sweep,ledger,snippets} ..." + "ai-artifacts,ai-readme-check,build-info,repair,migrate-style,sweep,ledger,snippets} ..." ), ( "make-docsrc", @@ -84,6 +84,8 @@ def _usage(argv): "[-d] [-w] project-dir" ), ("snippets",): "usage: epythet snippets [-h] {list,show,init,diff} ...", + # WP7 (0.2.6): build provenance as JSON; -n/--no-pypi skips the network lookup. + ("build-info",): "usage: epythet build-info [-h] [-n] project-dir", # v2 (0.2.3): the source-editing and fleet commands, and the ledger group. ("repair",): ( "usage: epythet repair [-h] [-w] [-f FENCE_STYLE] [-i [IGNORE ...]] [-l LEDGER] " @@ -122,6 +124,7 @@ def test_command_set_and_order(): "validate", "ai-artifacts", "ai-readme-check", + "build-info", "repair", "migrate-style", "sweep", diff --git a/tests/test_config.py b/tests/test_config.py index 146472d..2077490 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -214,7 +214,10 @@ def test_auto_generator_resolves_by_import_probe(tmp_path, capsys): assert "a_dependency_that_is_missing" in capsys.readouterr().err pinned = DocsConfig( - project_dir=bad, name="badpkg", package_dir="badpkg", api_generator="autosummary" + project_dir=bad, + name="badpkg", + package_dir="badpkg", + api_generator="autosummary", ) assert pinned.resolved_api_generator == "autosummary" diff --git a/tests/test_coverage.py b/tests/test_coverage.py index 840e040..8f221dc 100644 --- a/tests/test_coverage.py +++ b/tests/test_coverage.py @@ -19,7 +19,9 @@ def _specimens(): for rule in catalog.of_kind("coverage"): for case in iter_coverage_cases(rule.fixture_path): if rule.id in case.rule_ids: - yield pytest.param(rule.id, case, id=f"{rule.id}:{case.name.split('.')[-1]}") + yield pytest.param( + rule.id, case, id=f"{rule.id}:{case.name.split('.')[-1]}" + ) @pytest.mark.parametrize("rule_id,case", list(_specimens())) @@ -31,8 +33,12 @@ def test_coverage_specimen(rule_id, case): def test_every_coverage_rule_has_both_specimens(): for rule in load_ledger().of_kind("coverage"): - cases = [c for c in iter_coverage_cases(rule.fixture_path) if rule.id in c.rule_ids] - assert any(c.expect_hit for c in cases) and any(not c.expect_hit for c in cases), rule.id + cases = [ + c for c in iter_coverage_cases(rule.fixture_path) if rule.id in c.rule_ids + ] + assert any(c.expect_hit for c in cases) and any( + not c.expect_hit for c in cases + ), rule.id @pytest.mark.parametrize( @@ -74,15 +80,31 @@ def test_public_surface_and_entry_points(make_project): def test_all_in_init_narrows_entry_points(make_project): - project = make_project("pkg", {"mod.py": '"""Mod."""\n\n\ndef a():\n """A."""\n\n\ndef b():\n """B."""\n'}, init='"""P."""\n__all__ = ["a"]\nfrom pkg.mod import a, b\n') + project = make_project( + "pkg", + { + "mod.py": '"""Mod."""\n\n\ndef a():\n """A."""\n\n\ndef b():\n """B."""\n' + }, + init='"""P."""\n__all__ = ["a"]\nfrom pkg.mod import a, b\n', + ) assert entry_point_names(project / "pkg") == {"a"} objects = {o.qualname: o for o in iter_public_objects(project / "pkg")} - assert objects["pkg.mod.a"].is_entry_point and not objects["pkg.mod.b"].is_entry_point + assert ( + objects["pkg.mod.a"].is_entry_point and not objects["pkg.mod.b"].is_entry_point + ) def test_level_0_without_linters_reports_coverage_only(make_project, tmp_path): - project = make_project("pkg", {"mod.py": '"""Mod."""\n\n\ndef load_config(path):\n """Load the config."""\n\n\ndef bare(x):\n return x\n'}, init='"""P."""\nfrom pkg.mod import load_config\n') - report = validate(project, level=0, linters=False, observations_path=tmp_path / "obs.jsonl") + project = make_project( + "pkg", + { + "mod.py": '"""Mod."""\n\n\ndef load_config(path):\n """Load the config."""\n\n\ndef bare(x):\n return x\n' + }, + init='"""P."""\nfrom pkg.mod import load_config\n', + ) + report = validate( + project, level=0, linters=False, observations_path=tmp_path / "obs.jsonl" + ) rules = sorted(f.rule for f in report.findings) assert rules == ["DQ001", "DQ002", "DQ003"] assert report.objects_checked == 4 and report.objects_undocumented == 1 diff --git a/tests/test_migrate.py b/tests/test_migrate.py index c92ef1c..5b00d19 100644 --- a/tests/test_migrate.py +++ b/tests/test_migrate.py @@ -5,9 +5,18 @@ import pytest import cw -from epythet.migrate import convert_fields, field_region, migrate_style, migrate_style_command, rst_fields_to_sections - -pytestmark = pytest.mark.skipif(importlib.util.find_spec("docstring_parser") is None, reason="needs docstring_parser") +from epythet.migrate import ( + convert_fields, + field_region, + migrate_style, + migrate_style_command, + rst_fields_to_sections, +) + +pytestmark = pytest.mark.skipif( + importlib.util.find_spec("docstring_parser") is None, + reason="needs docstring_parser", +) MODULE = '''\ """Module.""" @@ -69,7 +78,17 @@ def m(self, a): def test_field_region_bounds(): - lines = ["Summary.", "", ":param x: the x", " more", "", ":returns: y", "", "Then prose.", ":param late: no"] + lines = [ + "Summary.", + "", + ":param x: the x", + " more", + "", + ":returns: y", + "", + "Then prose.", + ":param late: no", + ] assert field_region(lines) == (2, 6) assert field_region(["No fields."]) is None assert field_region([":param x: x", " >>> not_a_continuation()"]) == (0, 1) @@ -77,13 +96,18 @@ def test_field_region_bounds(): def test_convert_fields_google_and_numpy(): region = ":param x: the x value\n:type x: int\n:returns: x doubled\n:rtype: int" - assert convert_fields(region, to="google") == "Args:\n x (int): the x value\n\nReturns:\n int: x doubled" + assert ( + convert_fields(region, to="google") + == "Args:\n x (int): the x value\n\nReturns:\n int: x doubled" + ) numpy = convert_fields(region, to="numpy") assert numpy.startswith("Parameters\n----------\nx : int\n the x value") def test_convert_fields_refuses_what_does_not_round_trip(): - assert convert_fields(":param x: the x\n:keyword verbose: chatty", to="google") is None + assert ( + convert_fields(":param x: the x\n:keyword verbose: chatty", to="google") is None + ) assert convert_fields(":var foo: bar", to="google") is None assert convert_fields("no fields here", to="google") is None with pytest.raises(ValueError): diff --git a/tests/test_provenance.py b/tests/test_provenance.py new file mode 100644 index 0000000..b0894b4 --- /dev/null +++ b/tests/test_provenance.py @@ -0,0 +1,483 @@ +"""Unit tests for :mod:`epythet.provenance`: collection, degradation, rendering. + +The Sphinx smoke build in ``test_build.py`` covers the rendered footer, page +and JSON; here the record itself is exercised without a build: a git fixture +(clean, dirty, tagged, detached), the CI environment, no git at all, the JSON +key set that ``schema_version`` promises, and the ``provenance`` config seam. +""" + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +from epythet.config import ConfigError, DocsConfig, load_config +from epythet.provenance import ( + ABOUT_PAGE_DOCNAME, + SCHEMA_VERSION, + alignment, + ci_info, + clone_dirname, + collect_build_info, + compare_versions, + footer_text, + git_info, + github_web_url, + pypi_info, + render_about_page, + render_footer_line, + scrub_paths, + strip_credentials, +) + +GIT = ["git", "-c", "user.email=jane@example.com", "-c", "user.name=Jane"] + + +def _git(root, *args): + return subprocess.run( + [*GIT, *args], cwd=root, check=True, capture_output=True, text=True + ).stdout.strip() + + +@pytest.fixture +def repo(make_project) -> Path: + """A committed project with a GitHub remote and a tag on HEAD.""" + root = make_project("pkg", {"core.py": '"""Core."""\n'}) + (root / "docsrc").mkdir() + (root / "docsrc" / "index.md").write_text("# committed docsrc\n") + _git(root, "init", "-q", "-b", "main") + _git(root, "add", "-A") + _git(root, "commit", "-qm", "init") + _git(root, "remote", "add", "origin", "git@github.com:org/pkg.git") + _git(root, "tag", "v0.0.1") + return root + + +# -------------------------------------------------------------------------- +# git +# -------------------------------------------------------------------------- + + +def test_git_info_clean_tagged_repo(repo): + info = git_info(repo) + assert info["available"] and info["error"] is None + assert info["commit"] == _git(repo, "rev-parse", "HEAD") + assert info["short_commit"] == info["commit"][:7] + assert info["branch"] == "main" and info["tags"] == ["v0.0.1"] + assert info["dirty"] is False + assert info["remote_url"] == "git@github.com:org/pkg.git" + assert info["commit_url"] == f"https://github.com/org/pkg/commit/{info['commit']}" + + +def test_git_info_dirty_flag_ignores_the_docs_dir(repo): + # The build rewrites a committed docsrc/: that must not count as dirty. + (repo / "docsrc" / "index.md").write_text("# regenerated by the build\n") + assert git_info(repo, exclude=("docsrc/",))["dirty"] is False + assert git_info(repo)["dirty"] is True + (repo / "pkg" / "core.py").write_text('"""Changed."""\n') + assert git_info(repo, exclude=("docsrc/",))["dirty"] is True + + +def test_git_info_untracked_files_do_not_count(repo): + (repo / "scratch.txt").write_text("x") + assert git_info(repo)["dirty"] is False + + +def test_git_info_detached_head_has_no_branch_name(repo): + _git(repo, "checkout", "-q", "--detach") + assert git_info(repo)["branch"] is None + page = render_about_page(collect_build_info(load_config(repo), check_pypi=False)) + assert "| Branch | none (detached HEAD) |" in page + + +def test_git_info_without_a_repository(tmp_path, monkeypatch): + # Even when pytest's temp dir sits inside some repository. + monkeypatch.setenv("GIT_CEILING_DIRECTORIES", str(tmp_path)) + info = git_info(tmp_path / "project") + assert info["available"] is False and info["commit"] is None + assert info["dirty"] is None and info["tags"] == [] + assert info["error"] + + +def test_github_web_url_forms(): + assert github_web_url("git@github.com:o/r.git") == "https://github.com/o/r" + assert github_web_url("https://github.com/o/r") == "https://github.com/o/r" + assert github_web_url("ssh://git@github.com/o/r.git") == "https://github.com/o/r" + assert github_web_url("https://gitlab.com/o/r.git") is None + + +def test_remote_credentials_never_reach_the_record(repo): + _git( + repo, "remote", "set-url", "origin", "https://me:ghp_secret@github.com/o/r.git" + ) + info = git_info(repo) + assert "ghp_secret" not in json.dumps(info) + assert info["remote_url"] == "https://github.com/o/r.git" + assert strip_credentials("git@github.com:o/r.git") == "git@github.com:o/r.git" + + +@pytest.mark.parametrize( + "remote, published", + [ + ("git@github.com:o/r.git", "git@github.com:o/r.git"), + ("thor@myserver.local:repos/demo.git", "myserver.local:repos/demo.git"), + ("ssh://me:pw@host.example/r.git", "ssh://host.example/r.git"), + ("/Users/someone/bare/demo.git", None), + ("C:\\Users\\someone\\repo", None), + ("https://github.com/o/r.git/", "https://github.com/o/r.git"), + ("file:///srv/git/demo.git", None), + ("../sibling-checkout", None), + ], +) +def test_local_paths_and_users_never_reach_the_record(repo, remote, published): + _git(repo, "remote", "set-url", "origin", remote) + info = collect_build_info(load_config(repo), check_pypi=False) + assert info["git"]["remote_url"] == published + assert "someone" not in json.dumps(info) and "thor@" not in json.dumps(info) + if published: + clone_dir = clone_dirname(published) + assert f"git clone {published} && cd {clone_dir}" in info["reproduce"] + else: + assert "git clone" not in info["reproduce"] + assert repo.name not in info["reproduce"] # never the local folder name + + +def test_git_errors_are_scrubbed_of_paths(): + assert "/" not in scrub_paths("fatal: dubious ownership in repository at '/w/x'") + assert "C:" not in scrub_paths("fatal: cannot open C:\\Users\\me\\x") + + +# -------------------------------------------------------------------------- +# CI and the full record +# -------------------------------------------------------------------------- + +CI_ENV = { + "GITHUB_ACTIONS": "true", + "GITHUB_REPOSITORY": "org/pkg", + "GITHUB_RUN_ID": "123", + "GITHUB_REF": "refs/heads/main", + "GITHUB_REF_NAME": "main", + "GITHUB_SERVER_URL": "https://github.com", +} + + +def test_ci_info_outside_ci_is_all_none(): + assert set(ci_info({}).values()) == {None} + + +def test_ci_context_fills_branch_and_commit_url_when_git_cannot(tmp_path, make_project): + root = make_project("pkg", {}) + env = {**CI_ENV, "GITHUB_SHA": "a" * 40, "EPYTHET_PYPI_CHECK": "0"} + info = collect_build_info(load_config(root), environ=env) + assert info["ci"]["run_url"] == "https://github.com/org/pkg/actions/runs/123" + assert info["git"]["available"] is False + assert ( + info["git"]["commit"] == "a" * 40 and info["git"]["short_commit"] == "aaaaaaa" + ) + assert info["git"]["branch"] == "main" + assert info["git"]["commit_url"] == "https://github.com/org/pkg/commit/" + "a" * 40 + assert any(w.startswith("git:") for w in info["warnings"]) + + +def test_ci_sha_outside_history_is_a_misalignment_note(repo): + env = {**CI_ENV, "GITHUB_SHA": "b" * 40, "EPYTHET_PYPI_CHECK": "0"} + info = collect_build_info(load_config(repo), environ=env) + assert info["ci"]["sha_in_history"] is False + assert info["alignment"]["aligned"] is False + assert any("CI checkout" in note for note in info["alignment"]["notes"]) + + +def test_ci_sha_in_history_is_fine(repo): + """The action fast-forwards past the event commit (the wads version bump).""" + event_sha = _git(repo, "rev-parse", "HEAD") + (repo / "pkg" / "core.py").write_text('"""Bumped."""\n') + _git(repo, "commit", "-qam", "bump [skip ci]") + env = {**CI_ENV, "GITHUB_SHA": event_sha, "EPYTHET_PYPI_CHECK": "0"} + info = collect_build_info(load_config(repo), environ=env) + assert info["ci"]["sha_in_history"] is True + assert info["git"]["commit"] != event_sha + assert info["alignment"] == {"aligned": True, "notes": []} + assert "in the history of the built commit" in render_about_page(info) + + +def test_source_date_epoch_fixes_the_build_time(repo): + env = {"SOURCE_DATE_EPOCH": "0", "EPYTHET_PYPI_CHECK": "0"} + info = collect_build_info(load_config(repo), environ=env) + assert info["built_at"] == "1970-01-01T00:00:00Z" + assert footer_text(info).startswith("built 1970-01-01 00:00 UTC") + + +def test_copy_target_output_does_not_count_as_dirty(repo): + (repo / "docs").mkdir() + (repo / "docs" / "index.html").write_text("old") + _git(repo, "add", "docs") + _git(repo, "commit", "-qm", "site") + (repo / "docs" / "index.html").write_text("rebuilt by `epythet make . github`") + assert ( + collect_build_info(load_config(repo), check_pypi=False)["git"]["dirty"] is False + ) + assert git_info(repo)["dirty"] is True + + +@pytest.mark.skipif( + sys.platform == "win32", reason="Windows git refuses such ref names" +) +def test_ref_names_are_escaped_in_the_about_page(repo): + _git(repo, "checkout", "-q", "-b", "x`BOLD`y") + _git(repo, "tag", "t|pipe") + info = collect_build_info(load_config(repo), check_pypi=False) + page = render_about_page(info) + assert "BOLD" not in page and "<b>BOLD</b>" in page + assert "t|pipe, v0.0.1" in page + assert "`<b>BOLD</b>`" in page # no code span opened + line = render_footer_line(info) + assert "" not in line and "<b>" in line + + +EXPECTED_KEYS = { + "schema_version", + "built_at", + "package", + "git", + "ci", + "tools", + "config", + "site", + "pypi", + "reproduce", + "warnings", + "alignment", +} + + +def test_record_schema_is_stable_and_json_serialisable(repo): + info = collect_build_info(load_config(repo), check_pypi=False) + assert set(info) == EXPECTED_KEYS and info["schema_version"] == SCHEMA_VERSION + assert set(info["package"]) == {"name", "version", "display_name", "source"} + assert set(info["git"]) == { + "available", + "commit", + "short_commit", + "branch", + "tags", + "dirty", + "remote_url", + "commit_url", + "error", + } + assert set(info["ci"]) == { + "provider", + "repository", + "sha", + "sha_in_history", + "ref", + "ref_name", + "run_id", + "run_url", + "server_url", + } + assert set(info["tools"]) == {"epythet", "sphinx", "docutils", "python"} + assert set(info["config"]) == { + "theme", + "html_theme", + "accent", + "mode", + "api_generator", + "ignore", + "agent_outputs", + "aggregates", + "ai_artifacts", + "provenance", + "docs_dir", + } + assert set(info["site"]) == {"modules_documented", "objects_documented"} + assert set(info["pypi"]) == {"checked", "latest", "relation", "error"} + assert set(info["alignment"]) == {"aligned", "notes"} + assert info["package"]["source"] == "pyproject.toml" + assert info["built_at"].endswith("Z") + assert info["alignment"] == {"aligned": True, "notes": []} + assert "git clone git@github.com:org/pkg.git && cd pkg" in info["reproduce"] + assert "git checkout " + info["git"]["commit"] in info["reproduce"] + json.dumps(info) # every value is plain JSON + + +def test_record_without_git_says_so(make_project): + info = collect_build_info(load_config(make_project("pkg", {})), check_pypi=False) + assert info["git"]["available"] is False + assert info["alignment"]["aligned"] is None + assert "unknown" in info["alignment"]["notes"][-1] + assert "git checkout" not in info["reproduce"] + text = footer_text(info) + assert text.startswith("built ") and " from " not in text and "pkg 0.0.1" in text + + +# -------------------------------------------------------------------------- +# PyPI +# -------------------------------------------------------------------------- + + +def test_compare_versions(): + assert compare_versions("0.2.4", "0.2.5") == "behind" + assert compare_versions("0.3.0", "0.2.5") == "ahead" + assert compare_versions("1.0", "1.0.0") == "same" + assert compare_versions("not-a-version", "1.0") == "unknown" + + +def test_pypi_lookup_failure_degrades_to_not_checked(monkeypatch): + import epythet.provenance as prov + + def boom(name, *, timeout): + raise OSError("network is unreachable") + + monkeypatch.setattr(prov, "pypi_latest_version", boom) + info = pypi_info("pkg", "1.0") + assert info == { + "checked": False, + "latest": None, + "relation": "unknown", + "error": "OSError: network is unreachable", + } + + +def test_pypi_behind_and_ahead_notes(monkeypatch, repo): + import epythet.provenance as prov + + monkeypatch.setattr(prov, "pypi_latest_version", lambda name, *, timeout: "0.0.2") + info = collect_build_info(load_config(repo), check_pypi=True) + assert info["pypi"] == { + "checked": True, + "latest": "0.0.2", + "relation": "behind", + "error": None, + } + assert info["alignment"]["aligned"] is False + assert "behind the latest release on PyPI (0.0.2)" in info["alignment"]["notes"][0] + page = render_about_page(info) + assert "newer than the documented version (0.0.1)" in page + monkeypatch.setattr(prov, "pypi_latest_version", lambda name, *, timeout: None) + info = collect_build_info(load_config(repo), check_pypi=True) + assert ( + info["pypi"]["relation"] == "unknown" and info["alignment"]["aligned"] is True + ) + assert "is not on PyPI" in render_about_page(info) + + +def test_pypi_env_switch(monkeypatch, repo): + import epythet.provenance as prov + + calls = [] + monkeypatch.setattr( + prov, "pypi_latest_version", lambda name, *, timeout: calls.append(name) + ) + collect_build_info(load_config(repo), environ={"EPYTHET_PYPI_CHECK": "0"}) + assert calls == [] + collect_build_info(load_config(repo), environ={}) + assert calls == ["pkg"] + + +# -------------------------------------------------------------------------- +# rendering +# -------------------------------------------------------------------------- + + +def test_footer_line_html(repo): + info = collect_build_info(load_config(repo), check_pypi=False) + (repo / "pkg" / "core.py").write_text('"""Changed."""\n') + dirty = collect_build_info(load_config(repo), check_pypi=False) + line = render_footer_line(dirty) + assert line.startswith('

{dirty["git"]["short_commit"]}+dirty (main)' + in line + ) + assert "· pkg 0.0.1 ·" in line + assert f'about this build' in line + assert "+dirty" not in render_footer_line(info) + assert 'build info' in render_footer_line( + info, about_href=None + ) + + +def test_about_page_content(repo): + info = collect_build_info( + load_config(repo), + environ={**CI_ENV, "GITHUB_SHA": info_sha(repo)}, + check_pypi=False, + ) + page = render_about_page(info) + assert page.startswith("---\norphan: true\n---\n") + assert ":::{note}" in page and "Nothing suggests a mismatch" in page + assert "| Tags at this commit | v0.0.1 |" in page + assert "| Working tree | clean |" in page + assert '123' in page + assert ( + "| ignore | tests/, scrap/, examples/ |" + in page + ) + assert "Not checked." in page + assert '' in page + + +def info_sha(repo): + return _git(repo, "rev-parse", "HEAD") + + +def test_about_page_custom_template_uses_a_subset_of_fields(repo): + info = collect_build_info(load_config(repo), check_pypi=False) + page = render_about_page(info, template="{marker}\n# Build\n\n{summary}\n") + assert page.startswith("\n# Build") + assert "pkg 0.0.1" in page + + +def test_alignment_dirty_note(): + info = { + "git": { + "available": True, + "dirty": True, + "commit": "c" * 40, + "short_commit": "ccccccc", + }, + "pypi": {"checked": False, "latest": None, "relation": "unknown"}, + "ci": {"sha": None}, + "package": {"name": "pkg", "version": "1"}, + } + result = alignment(info) + assert result["aligned"] is False and "uncommitted changes" in result["notes"][0] + + +# -------------------------------------------------------------------------- +# the config seam +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "value, expected", + [("true", True), ("false", False), ('"minimal"', "minimal")], +) +def test_provenance_key_from_pyproject(tmp_path, value, expected): + (tmp_path / "pyproject.toml").write_text( + f'[project]\nname = "x"\n[tool.epythet]\nprovenance = {value}\n' + ) + assert load_config(tmp_path).provenance == expected + + +@pytest.mark.parametrize( + "value, expected", + [("true", True), ("off", False), ("Minimal", "minimal")], +) +def test_provenance_key_from_setup_cfg(tmp_path, value, expected): + (tmp_path / "setup.cfg").write_text( + f"[metadata]\nname = x\n[tool.epythet]\nprovenance = {value}\n" + ) + assert load_config(tmp_path).provenance == expected + + +def test_provenance_key_rejects_other_values(): + with pytest.raises(ConfigError, match="provenance"): + DocsConfig(project_dir="/tmp/x", name="x", provenance="verbose") + + +def test_default_is_on(): + assert DocsConfig(project_dir="/tmp/x", name="x").provenance is True diff --git a/tests/test_repair.py b/tests/test_repair.py index 8478e8d..65d26ae 100644 --- a/tests/test_repair.py +++ b/tests/test_repair.py @@ -143,7 +143,10 @@ def test_split_literal_shapes(): @pytest.mark.parametrize( "literal,expected_reason", [ - ('"""Has \\n escape."""', "non-raw literal with backslashes: escapes would change"), + ( + '"""Has \\n escape."""', + "non-raw literal with backslashes: escapes would change", + ), ("b'''bytes'''", "bytes or f-string literal"), ("'One\\n>>> f()'", "non-raw literal with backslashes: escapes would change"), ], @@ -154,9 +157,14 @@ def test_unsafe_literals_are_refused_with_a_reason(literal, expected_reason): def test_single_quoted_docstring_that_would_grow_is_refused(): - literal = "'Text\n>>> f()'" # a (syntactically odd) single-quoted literal spanning lines + literal = ( + "'Text\n>>> f()'" # a (syntactically odd) single-quoted literal spanning lines + ) new, reason = rewrite_docstring_literal(literal) - assert new == literal and reason == "single-quoted docstring would need a triple-quoted rewrite" + assert ( + new == literal + and reason == "single-quoted docstring would need a triple-quoted rewrite" + ) def test_rewrite_keeps_margin_and_closing_line(): @@ -181,9 +189,16 @@ def test_repair_source_dry_run_matches_the_golden_diff(tmp_path): path.write_text(MODULE) result = repair_source(MODULE, rules=rules_for("code-block"), path=path) assert result.diff() == EXPECTED_DIFF - assert [e.qualname for e in result.applied] == ["", "glued", "fenced", "K.m"] + assert [e.qualname for e in result.applied] == [ + "", + "glued", + "fenced", + "K.m", + ] refused = {e.qualname: e.reason for e in result.refused} - assert refused == {"escaped": "non-raw literal with backslashes: escapes would change"} + assert refused == { + "escaped": "non-raw literal with backslashes: escapes would change" + } def test_repair_source_keeps_the_ast_outside_docstrings(): @@ -219,7 +234,13 @@ def test_fence_style_literal(): def test_repair_dry_run_writes_nothing(make_project): project = make_project("rpkg", {"mod.py": MODULE}) report = repair(project) - assert report.counts() == {"files": 2, "files_changed": 1, "docstrings_rewritten": 4, "docstrings_refused": 2, "files_written": 0} + assert report.counts() == { + "files": 2, + "files_changed": 1, + "docstrings_rewritten": 4, + "docstrings_refused": 2, + "files_written": 0, + } assert (project / "rpkg" / "mod.py").read_text() == MODULE assert report.diff() == EXPECTED_DIFF @@ -237,13 +258,19 @@ def test_repair_write_is_verified_and_idempotent(make_project): assert (project / "rpkg" / "mod.py").read_text() == repaired -def test_repair_write_restores_a_file_whose_doctests_got_worse(make_project, monkeypatch): +def test_repair_write_restores_a_file_whose_doctests_got_worse( + make_project, monkeypatch +): project = make_project("rpkg", {"mod.py": MODULE}) calls = iter([(0, 0, "ok"), (1, 2, "***Test Failed*** 2 failures.")]) monkeypatch.setattr("epythet.repair._doctest_failures", lambda *a, **k: next(calls)) report = repair(project, write=True) assert not report.changed[0].written - assert report.changed[0].verification[0].startswith("restored: doctest failures went from 0 to 2") + assert ( + report.changed[0] + .verification[0] + .startswith("restored: doctest failures went from 0 to 2") + ) assert (project / "rpkg" / "mod.py").read_text() == MODULE @@ -258,7 +285,9 @@ def test_repair_package_delegates_and_keeps_its_shape(make_project, capsys): total = repair_package(str(project / "rpkg")) out = capsys.readouterr().out assert total == 4 - assert out.startswith("---> This is just a diagnosis: No files are being written to") + assert out.startswith( + "---> This is just a diagnosis: No files are being written to" + ) assert "mod.py" in out and "#problems: 4" in out assert (project / "rpkg" / "mod.py").read_text() == MODULE assert repair_package(str(project / "rpkg"), write_to_files=True) == 4 @@ -282,6 +311,10 @@ def test_repair_command_exit_codes(make_project, capsys): def test_libcst_applier_matches_span_applier(): from epythet.repair import apply_with_libcst - span = repair_source(MODULE, rules=rules_for("code-block"), applier=apply_span_edits) - cst = repair_source(MODULE, rules=rules_for("code-block"), applier=apply_with_libcst) + span = repair_source( + MODULE, rules=rules_for("code-block"), applier=apply_span_edits + ) + cst = repair_source( + MODULE, rules=rules_for("code-block"), applier=apply_with_libcst + ) assert span.repaired == cst.repaired diff --git a/tests/test_repair_edge_cases.py b/tests/test_repair_edge_cases.py index 3a510a6..c31ce85 100644 --- a/tests/test_repair_edge_cases.py +++ b/tests/test_repair_edge_cases.py @@ -9,7 +9,12 @@ import cw from epythet.cli import mk_epythet_parser from epythet.migrate import convert_fields, field_regions, migrate_style -from epythet.repair import IMPORT_FAILED, repair, repair_source, rewrite_docstring_literal +from epythet.repair import ( + IMPORT_FAILED, + repair, + repair_source, + rewrite_docstring_literal, +) from epythet.tools import repair_package from epythet.validation.core import resolve_package from epythet.validation.rendered import dangling_anchors @@ -27,7 +32,13 @@ def test_ignore_takes_several_values_on_every_command(): def test_ignore_is_a_path_substring_not_characters(make_project): - project = make_project("pkg", {"mod.py": '"""M."""\n\n\ndef f():\n """Do.\n >>> f()\n """\n', "tests_helper.py": '"""T."""\n\n\ndef g():\n """Do.\n >>> g()\n """\n'}) + project = make_project( + "pkg", + { + "mod.py": '"""M."""\n\n\ndef f():\n """Do.\n >>> f()\n """\n', + "tests_helper.py": '"""T."""\n\n\ndef g():\n """Do.\n >>> g()\n """\n', + }, + ) report = repair(project, ignore=["tests_"]) assert [f.path.name for f in report.files] == ["__init__.py", "mod.py"] @@ -55,7 +66,7 @@ def test_unparseable_doctest_is_refused_not_crashed(): literal = '"""Do.\n\n >>> f()\n 1\n """' # output less indented than its prompt new, reason = rewrite_docstring_literal(literal) assert new == literal and reason.startswith("doctest could not parse") - result = repair_source('def f():\n ' + literal + "\n") + result = repair_source("def f():\n " + literal + "\n") assert result.refused and not result.changed @@ -68,19 +79,26 @@ def test_first_line_one_liner_section_uses_the_leading_newline_form(): new, reason = rewrite_docstring_literal(literal) assert reason is None assert new == '"""\n Returns:\n the x\n doubled.\n """' - assert inspect.cleandoc(split_literal(new)[2]) == "Returns:\n the x\n doubled." + assert ( + inspect.cleandoc(split_literal(new)[2]) == "Returns:\n the x\n doubled." + ) assert rewrite_docstring_literal(new)[0] == new # idempotent def test_form_feed_earlier_in_the_file_does_not_shift_offsets(): - source = "x = 1\n\x0c\ny = 'é'; z = 2\n\n\ndef f():\n \"\"\"Do.\n >>> f()\n \"\"\"\n" + source = 'x = 1\n\x0c\ny = \'é\'; z = 2\n\n\ndef f():\n """Do.\n >>> f()\n """\n' result = repair_source(source) assert result.applied and result.applied[0].qualname == "f" assert result.repaired.startswith("x = 1\n\x0c\ny = 'é'; z = 2\n") def test_unimportable_module_is_written_but_reported_unverified(make_project): - project = make_project("pkg", {"mod.py": 'from .nowhere import thing # noqa\n\n\ndef f():\n """Do.\n >>> f()\n """\n'}) + project = make_project( + "pkg", + { + "mod.py": 'from .nowhere import thing # noqa\n\n\ndef f():\n """Do.\n >>> f()\n """\n' + }, + ) report = repair(project, write=True) changed = report.changed[0] assert changed.written and changed.verification[0].startswith("not verified") @@ -89,16 +107,23 @@ def test_unimportable_module_is_written_but_reported_unverified(make_project): def test_doctest_gate_uses_the_dotted_import_so_relative_imports_work(make_project): project = make_project( "pkg", - {"helper.py": '"""H."""\nVALUE = 1\n', "mod.py": 'from .helper import VALUE\n\n\ndef f():\n """Do.\n >>> f()\n 1\n """\n return VALUE\n'}, + { + "helper.py": '"""H."""\nVALUE = 1\n', + "mod.py": 'from .helper import VALUE\n\n\ndef f():\n """Do.\n >>> f()\n 1\n """\n return VALUE\n', + }, ) report = repair(project, write=True) assert report.changed[0].verification == ["doctests: 0 failure(s) before, 0 after"] def test_refused_count_is_stable_across_passes(make_project): - project = make_project("pkg", {"mod.py": 'def f(*args):\n """Takes *args.\n >>> f()\n """\n'}) + project = make_project( + "pkg", {"mod.py": 'def f(*args):\n """Takes *args.\n >>> f()\n """\n'} + ) dry = repair(project) - assert dry.counts()["docstrings_refused"] == 1 # rewritten (blank line) but DR010 remains + assert ( + dry.counts()["docstrings_refused"] == 1 + ) # rewritten (blank line) but DR010 remains repair(project, write=True, run_doctests=False) assert repair(project).counts()["docstrings_refused"] == 1 @@ -117,7 +142,10 @@ def test_repair_package_accepts_a_plain_directory(tmp_path, capsys): def test_untyped_return_has_no_stray_colon(): - assert convert_fields(":param x: the x\n:returns: y", to="google") == "Args:\n x: the x\n\nReturns:\n y" + assert ( + convert_fields(":param x: the x\n:returns: y", to="google") + == "Args:\n x: the x\n\nReturns:\n y" + ) def test_two_field_blocks_are_left_alone_with_a_reason(make_project): @@ -150,17 +178,29 @@ def test_proposed_rule_yaml_quotes_every_scalar(): "example_good": "g", } data = yaml.safe_load(rule_yaml("DS009", proposal, source="s")) - assert data["severity"] == "info" and data["fix"]["strategy"] == "x\nseverity: error" + assert ( + data["severity"] == "info" and data["fix"]["strategy"] == "x\nseverity: error" + ) def test_propose_rejects_examples_with_both_triple_quotes(tmp_path): reply = { - "schema_version": "1", "model": "m", "prompt_hash": "0123456789abcdef", "findings": [], - "proposed_rules": [{ - "title": "T", "namespace": "semantics", "severity": "info", "precision": "low", - "detector": {"kind": "regex", "node": "paragraph", "pattern": "bad"}, - "message": "m", "example_bad": 'bad """ and \'\'\'', "example_good": "good", - }], + "schema_version": "1", + "model": "m", + "prompt_hash": "0123456789abcdef", + "findings": [], + "proposed_rules": [ + { + "title": "T", + "namespace": "semantics", + "severity": "info", + "precision": "low", + "detector": {"kind": "regex", "node": "paragraph", "pattern": "bad"}, + "message": "m", + "example_bad": "bad \"\"\" and '''", + "example_good": "good", + } + ], } path = tmp_path / "review.json" path.write_text(json.dumps(reply)) @@ -172,10 +212,15 @@ def test_propose_rejects_examples_with_both_triple_quotes(tmp_path): def test_src_layout_manifest_entry_resolves(tmp_path): project = tmp_path / "proj" (project / "src" / "thing").mkdir(parents=True) - (project / "pyproject.toml").write_text('[project]\nname = "thing"\nversion = "1"\n') + (project / "pyproject.toml").write_text( + '[project]\nname = "thing"\nversion = "1"\n' + ) (project / "src" / "thing" / "__init__.py").write_text('"""T."""\n') resolved = resolve_package(project / "src") - assert resolved.package_dir == project / "src" / "thing" and resolved.project_dir == project + assert ( + resolved.package_dir == project / "src" / "thing" + and resolved.project_dir == project + ) def test_theme_skip_links_are_not_dangling_anchors(): @@ -197,5 +242,9 @@ def versions(self): def build_warnings(self, project_dir): return BuildResult(returncode=0) - findings, _, artifacts = run_render_level(tmp_path, load_ledger(), backend=WarningsOnly(), outdir=tmp_path / "o") - assert [f.rule for f in findings] == ["NO_RENDER"] and findings[0].severity == "warning" + findings, _, artifacts = run_render_level( + tmp_path, load_ledger(), backend=WarningsOnly(), outdir=tmp_path / "o" + ) + assert [f.rule for f in findings] == ["NO_RENDER"] and findings[ + 0 + ].severity == "warning" diff --git a/tests/test_sweep.py b/tests/test_sweep.py index d2450cc..1f9c43b 100644 --- a/tests/test_sweep.py +++ b/tests/test_sweep.py @@ -6,7 +6,13 @@ import pytest import cw -from epythet.sweep import packages_from_manifest, queue_score, render_sweep, sweep, sweep_command +from epythet.sweep import ( + packages_from_manifest, + queue_score, + render_sweep, + sweep, + sweep_command, +) BAD = '''\ """Bad module.""" @@ -44,8 +50,16 @@ def fine(x): @pytest.fixture def two_projects(make_project): - bad = make_project("badpkg", {"mod.py": BAD}, init='"""Bad package."""\nfrom badpkg.mod import leaky, glued\n') - good = make_project("goodpkg", {"mod.py": CLEAN}, init='"""Good package."""\nfrom goodpkg.mod import fine\n') + bad = make_project( + "badpkg", + {"mod.py": BAD}, + init='"""Bad package."""\nfrom badpkg.mod import leaky, glued\n', + ) + good = make_project( + "goodpkg", + {"mod.py": CLEAN}, + init='"""Good package."""\nfrom goodpkg.mod import fine\n', + ) return bad, good @@ -53,10 +67,14 @@ def _mtimes(root): return {p: p.stat().st_mtime_ns for p in root.rglob("*") if p.is_file()} -def test_sweep_is_read_only_and_ranks_the_bad_package_first(two_projects, tmp_path, data_dir): +def test_sweep_is_read_only_and_ranks_the_bad_package_first( + two_projects, tmp_path, data_dir +): bad, good = two_projects before = {**_mtimes(bad), **_mtimes(good)} - result = sweep([bad, good, tmp_path / "missing"], observations_path=tmp_path / "obs.jsonl") + result = sweep( + [bad, good, tmp_path / "missing"], observations_path=tmp_path / "obs.jsonl" + ) assert {**_mtimes(bad), **_mtimes(good)} == before assert [p.name for p in result.swept] == ["badpkg", "goodpkg"] assert result.packages[2].error and "missing" in result.packages[2].path @@ -64,7 +82,11 @@ def test_sweep_is_read_only_and_ranks_the_bad_package_first(two_projects, tmp_pa rules = {row["rule"] for row in result.distribution()} assert {"DR001", "DR003", "DQ001", "DQ002"} <= rules bad_counts = result.swept[0].counts - assert bad_counts["DR001"] == 1 and bad_counts["DR003"] == 1 and bad_counts["DQ002"] == 1 + assert ( + bad_counts["DR001"] == 1 + and bad_counts["DR003"] == 1 + and bad_counts["DQ002"] == 1 + ) assert result.swept[1].counts == {} # observations went to the file, the sweep summary to the data dir assert (tmp_path / "obs.jsonl").exists() @@ -77,12 +99,19 @@ def test_distribution_rows_are_complete(two_projects, tmp_path, data_dir): result = sweep([bad, good], observations_path=tmp_path / "obs.jsonl", record=False) row = next(r for r in result.distribution() if r["rule"] == "DR001") assert row["findings"] == 1 and row["packages"] == 1 and row["package_share"] == 0.5 - assert row["severity"] == "error" and row["title"] == "RST field list leaked into prose" - assert row["per_100_objects"] == round(100 / sum(p.objects for p in result.swept), 2) + assert ( + row["severity"] == "error" + and row["title"] == "RST field list leaked into prose" + ) + assert row["per_100_objects"] == round( + 100 / sum(p.objects for p in result.swept), 2 + ) def test_queue_score_weights_entry_points_first(): - assert queue_score({"DQ002": 1}, severities={}) > queue_score({"DQ003": 3}, severities={}) + assert queue_score({"DQ002": 1}, severities={}) > queue_score( + {"DQ003": 3}, severities={} + ) assert queue_score({"DR001": 1}, severities={"DR001": "error"}) == 5.0 @@ -92,7 +121,12 @@ def test_manifest_is_read_only_and_parsed(two_projects, tmp_path, data_dir): manifest.write_text(f"# comment\n{bad}\n\nimport sys\n{good}\n") before = manifest.read_bytes() assert packages_from_manifest(manifest) == [bad, good] - result = sweep(manifest=manifest, observations_path=tmp_path / "obs.jsonl", record=False, limit=1) + result = sweep( + manifest=manifest, + observations_path=tmp_path / "obs.jsonl", + record=False, + limit=1, + ) assert [p.name for p in result.packages] == ["badpkg"] assert manifest.read_bytes() == before @@ -101,10 +135,14 @@ def test_render_and_cli_json(two_projects, tmp_path, data_dir, capsys): bad, good = two_projects result = sweep([bad, good], observations_path=tmp_path / "obs.jsonl", record=False) text = render_sweep(result) - assert text.startswith("epythet sweep: 2 package(s) swept") and "queue (top 20" in text + assert ( + text.startswith("epythet sweep: 2 package(s) swept") and "queue (top 20" in text + ) assert "DR001 error" in text out = tmp_path / "sweep.json" - sweep_command(str(bad), str(good), no_observe=True, format="json", output=str(out), quiet=True) + sweep_command( + str(bad), str(good), no_observe=True, format="json", output=str(out), quiet=True + ) data = json.loads(out.read_text()) assert data["queue"][0]["name"] == "badpkg" and data["packages"][1]["counts"] == {} with pytest.raises(cw.CommandError) as info: diff --git a/tests/test_validation_rendered.py b/tests/test_validation_rendered.py index 0583309..089bf4c 100644 --- a/tests/test_validation_rendered.py +++ b/tests/test_validation_rendered.py @@ -95,7 +95,9 @@ def _text_dir(root: Path, pages: dict[str, str]) -> Path: def test_snapshots_update_then_compare(tmp_path): - rendered = _text_dir(tmp_path / "text", {"index": "Hello\n", "_autosummary/pkg": "pkg\n***\n"}) + rendered = _text_dir( + tmp_path / "text", {"index": "Hello\n", "_autosummary/pkg": "pkg\n***\n"} + ) snapshots = tmp_path / "snap" assert update_snapshots(rendered, snapshots) == 2 assert compare_snapshots(rendered, snapshots).clean @@ -183,22 +185,39 @@ def test_render_level_snapshot_drift_is_dr035(tmp_path, canned): ledger = load_ledger() snapshots = tmp_path / "snap" _, notes, _ = run_render_level( - tmp_path, ledger, backend=canned, outdir=tmp_path / "o1", update=True, snapshot_dir=snapshots + tmp_path, + ledger, + backend=canned, + outdir=tmp_path / "o1", + update=True, + snapshot_dir=snapshots, ) assert any("1 text snapshots written" in n for n in notes) canned.pages["text"]["index"] = "Hello there\n" findings, notes, artifacts = run_render_level( - tmp_path, ledger, backend=canned, outdir=tmp_path / "o2", snapshot=True, snapshot_dir=snapshots + tmp_path, + ledger, + backend=canned, + outdir=tmp_path / "o2", + snapshot=True, + snapshot_dir=snapshots, ) drift = [f for f in findings if f.rule == "DR035"] assert len(drift) == 1 and drift[0].severity == "error" and drift[0].file == "index" assert "+Hello there" in drift[0].evidence - assert artifacts.snapshot is not None and list(artifacts.snapshot.changed) == ["index"] + assert artifacts.snapshot is not None and list(artifacts.snapshot.changed) == [ + "index" + ] def test_render_level_without_snapshots_only_notes(tmp_path, canned): findings, notes, _ = run_render_level( - tmp_path, load_ledger(), backend=canned, outdir=tmp_path / "o", snapshot=True, snapshot_dir=tmp_path / "none" + tmp_path, + load_ledger(), + backend=canned, + outdir=tmp_path / "o", + snapshot=True, + snapshot_dir=tmp_path / "none", ) assert not [f for f in findings if f.rule == "DR035"] assert any("no snapshots" in n for n in notes) @@ -206,20 +225,30 @@ def test_render_level_without_snapshots_only_notes(tmp_path, canned): def test_render_level_failed_builder_is_a_build_finding(tmp_path, canned): canned.returncodes["xml"] = 2 - findings, _, artifacts = run_render_level(tmp_path, load_ledger(), backend=canned, outdir=tmp_path / "o") + findings, _, artifacts = run_render_level( + tmp_path, load_ledger(), backend=canned, outdir=tmp_path / "o" + ) build = [f for f in findings if f.rule == "BUILD"] assert len(build) == 1 and "-b xml failed" in build[0].message assert "xml" not in artifacts.outdirs def test_render_level_no_docsrc_is_non_gating(tmp_path): - backend = CannedBackend(pages={}, returncodes={b: NO_DOCSRC for b in ("html", "text", "xml")}) - findings, _, _ = run_render_level(tmp_path, load_ledger(), backend=backend, outdir=tmp_path / "o") - assert [f.rule for f in findings] == ["NO_DOCSRC"] and findings[0].severity == "warning" + backend = CannedBackend( + pages={}, returncodes={b: NO_DOCSRC for b in ("html", "text", "xml")} + ) + findings, _, _ = run_render_level( + tmp_path, load_ledger(), backend=backend, outdir=tmp_path / "o" + ) + assert [f.rule for f in findings] == ["NO_DOCSRC"] and findings[ + 0 + ].severity == "warning" def test_validate_tier_3_exit_code_is_13(make_project, canned, tmp_path): - project = make_project("pkg", {"mod.py": '"""Mod."""\n\n\ndef f(x):\n """Return x."""\n'}) + project = make_project( + "pkg", {"mod.py": '"""Mod."""\n\n\ndef f(x):\n """Return x."""\n'} + ) report = validate( project, level=3, @@ -229,15 +258,24 @@ def test_validate_tier_3_exit_code_is_13(make_project, canned, tmp_path): ) assert report.levels_run == [0, 0.5, 1, 2] assert report.failing_levels("warning") == [2] - assert report.exit_code() == EXIT_FOR_LEVEL[2] == 13 # DR037 (missing image) is an error - assert {f.rule for f in report.findings if f.level == 2} == {"DR026", "DR027", "DR036", "DR037"} + assert ( + report.exit_code() == EXIT_FOR_LEVEL[2] == 13 + ) # DR037 (missing image) is an error + assert {f.rule for f in report.findings if f.level == 2} == { + "DR026", + "DR027", + "DR036", + "DR037", + } # -------------------------------------------------------------------------- # A real render (Sphinx) # -------------------------------------------------------------------------- -sphinx_available = shutil.which("sphinx-build") is not None or __import__("importlib.util").util.find_spec("sphinx") +sphinx_available = shutil.which("sphinx-build") is not None or __import__( + "importlib.util" +).util.find_spec("sphinx") @pytest.mark.skipif(not sphinx_available, reason="needs sphinx") @@ -265,7 +303,14 @@ def undocumented(x): init='"""Package.\n\nSee :class:`rpkg.mod.Nowhere`.\n\n.. image:: missing.png\n"""\nfrom rpkg.mod import documented, undocumented\n', ) make_docsrc(project, verbose=False) - report = validate(project, levels=[2], observations_path=tmp_path / "obs.jsonl", render_dir=tmp_path / "render") + report = validate( + project, + levels=[2], + observations_path=tmp_path / "obs.jsonl", + render_dir=tmp_path / "render", + ) rules = {f.rule for f in report.findings} assert {"DR026", "DR036", "DR037"} <= rules, report.findings - assert (tmp_path / "render" / "text").is_dir() and (tmp_path / "render" / "xml").is_dir() + assert (tmp_path / "render" / "text").is_dir() and ( + tmp_path / "render" / "xml" + ).is_dir() diff --git a/tests/test_validation_review.py b/tests/test_validation_review.py index b9a425a..06d3507 100644 --- a/tests/test_validation_review.py +++ b/tests/test_validation_review.py @@ -22,7 +22,12 @@ @pytest.fixture def outdirs(tmp_path): text = tmp_path / "text" - for name, body in {"index": "Index.\n", "api": "API.\n", "_autosummary/pkg": "pkg\n", "_autosummary/pkg.mod": "mod\n"}.items(): + for name, body in { + "index": "Index.\n", + "api": "API.\n", + "_autosummary/pkg": "pkg\n", + "_autosummary/pkg.mod": "mod\n", + }.items(): target = text / f"{name}.txt" target.parent.mkdir(parents=True, exist_ok=True) target.write_text(body) @@ -32,7 +37,11 @@ def outdirs(tmp_path): def test_select_pages_modes(): pages = ["index", "api", "_autosummary/pkg", "_autosummary/pkg.mod"] assert select_pages(pages, mode="all") == pages - assert select_pages(pages, mode="sample", sample=3) == ["index", "_autosummary/pkg", "_autosummary/pkg.mod"] + assert select_pages(pages, mode="sample", sample=3) == [ + "index", + "_autosummary/pkg", + "_autosummary/pkg.mod", + ] assert select_pages(pages, mode="changed", changed=["api", "gone"]) == ["api"] assert select_pages(pages, mode="changed", changed=None, sample=1) == ["index"] with pytest.raises(ValueError): @@ -40,16 +49,38 @@ def test_select_pages_modes(): def test_packet_layout_and_manifest(outdirs, data_dir): - packet = write_packet(package="pkg", package_version="1.0", outdirs=outdirs, ledger=load_ledger(), mode="all") + packet = write_packet( + package="pkg", + package_version="1.0", + outdirs=outdirs, + ledger=load_ledger(), + mode="all", + ) assert packet.path.parent.parent == data_dir / "review" names = {p.name for p in packet.path.iterdir()} - assert {"packet.json", "rubric.md", "schema.json", "INSTRUCTIONS.md", "pages"} <= names - assert (packet.path / "pages" / "_autosummary" / "pkg.mod.txt").read_text() == "mod\n" + assert { + "packet.json", + "rubric.md", + "schema.json", + "INSTRUCTIONS.md", + "pages", + } <= names + assert ( + packet.path / "pages" / "_autosummary" / "pkg.mod.txt" + ).read_text() == "mod\n" manifest = json.loads((packet.path / "packet.json").read_text()) - assert manifest["pages"] == packet.pages and manifest["prompt_hash"] == packet.prompt_hash + assert ( + manifest["pages"] == packet.pages + and manifest["prompt_hash"] == packet.prompt_hash + ) rubric = (packet.path / "rubric.md").read_text() - assert "DR001: RST field list leaked into prose" in rubric and "| A. Summary |" in rubric - assert json.loads((data_dir / "review" / "pkg" / "latest.json").read_text())["packet"] == str(packet.path) + assert ( + "DR001: RST field list leaked into prose" in rubric + and "| A. Summary |" in rubric + ) + assert json.loads((data_dir / "review" / "pkg" / "latest.json").read_text())[ + "packet" + ] == str(packet.path) def _reply(prompt_hash="0123456789abcdef", **overrides): @@ -58,8 +89,22 @@ def _reply(prompt_hash="0123456789abcdef", **overrides): "model": "test-model", "prompt_hash": prompt_hash, "findings": [ - {"page": "index", "object": "pkg.f", "rule": "DR001", "severity": "warning", "message": "leaked", "evidence": ":param x:"}, - {"page": "api", "object": None, "rule": "proposed", "dimension": "A", "severity": "info", "message": "restates the name"}, + { + "page": "index", + "object": "pkg.f", + "rule": "DR001", + "severity": "warning", + "message": "leaked", + "evidence": ":param x:", + }, + { + "page": "api", + "object": None, + "rule": "proposed", + "dimension": "A", + "severity": "info", + "message": "restates the name", + }, ], "proposed_rules": [ { @@ -67,7 +112,11 @@ def _reply(prompt_hash="0123456789abcdef", **overrides): "namespace": "semantics", "severity": "info", "precision": "medium", - "detector": {"kind": "regex", "node": "paragraph", "pattern": r"\bthe thing\b"}, + "detector": { + "kind": "regex", + "node": "paragraph", + "pattern": r"\bthe thing\b", + }, "message": "placeholder: {match!r}", "fix": {"hint": "Say what it is."}, "example_bad": "Do it.\n\nArgs:\n x: the thing\n", @@ -87,7 +136,10 @@ def _reply(prompt_hash="0123456789abcdef", **overrides): (lambda r: r["findings"][0].__setitem__("rule", "bogus"), "does not match"), (lambda r: r["findings"][0].__setitem__("severity", "fatal"), "must be one of"), (lambda r: r.__setitem__("extra", 1), "unknown key"), - (lambda r: r["proposed_rules"][0].pop("example_good"), "missing required key 'example_good'"), + ( + lambda r: r["proposed_rules"][0].pop("example_good"), + "missing required key 'example_good'", + ), ], ) def test_reply_schema_is_strict(tmp_path, mutation, match): @@ -101,18 +153,34 @@ def test_reply_schema_is_strict(tmp_path, mutation, match): def test_review_level_writes_packet_and_ingests_reply(outdirs, data_dir, tmp_path): ledger = load_ledger() - findings, notes = run_review_level(package="pkg", package_version=None, outdirs=outdirs, ledger=ledger, mode="all") - assert [f.rule for f in findings] == [PACKET_RULE] and findings[0].level == 3 and findings[0].severity == "info" + findings, notes = run_review_level( + package="pkg", package_version=None, outdirs=outdirs, ledger=ledger, mode="all" + ) + assert ( + [f.rule for f in findings] == [PACKET_RULE] + and findings[0].level == 3 + and findings[0].severity == "info" + ) packet_dir = Path(findings[0].message.split("written to ")[1].split(" (")[0]) prompt_hash = json.loads((packet_dir / "packet.json").read_text())["prompt_hash"] reply_path = tmp_path / "review.json" reply_path.write_text(json.dumps(_reply(prompt_hash))) findings, notes = run_review_level( - package="pkg", package_version=None, outdirs=outdirs, ledger=ledger, mode="all", packet_dir=packet_dir, reply=reply_path + package="pkg", + package_version=None, + outdirs=outdirs, + ledger=ledger, + mode="all", + packet_dir=packet_dir, + reply=reply_path, ) rules = [f.rule for f in findings] assert rules == [PACKET_RULE, "DR001", "REVIEW-PROPOSED"] - assert findings[1].fix and findings[1].tool == "review:test-model" and findings[1].detector == "llm" + assert ( + findings[1].fix + and findings[1].tool == "review:test-model" + and findings[1].detector == "llm" + ) assert any("1 proposed rule(s)" in n for n in notes) assert not any("differs" in n for n in notes) @@ -120,7 +188,9 @@ def test_review_level_writes_packet_and_ingests_reply(outdirs, data_dir, tmp_pat def test_review_never_gates_unless_asked(): report = Report("p", "/p", [3], [Finding(PACKET_RULE, "info", 3, "packet")]) assert report.exit_code("info") == 0 - assert report.exit_code("info", fail_on_review=True) == 0 # a packet is not a defect + assert ( + report.exit_code("info", fail_on_review=True) == 0 + ) # a packet is not a defect report.findings.append(Finding("DR001", "error", 3, "reviewer")) assert report.exit_code("error") == 0 assert report.exit_code("error", fail_on_review=True) == EXIT_FOR_LEVEL[3] == 14 @@ -136,7 +206,9 @@ def test_next_rule_id_per_prefix(): assert next_rule_id(["DR001"], prefix="DS") == "DS001" -def test_propose_writes_a_loadable_proposed_rule_that_does_not_run_by_default(tmp_path, data_dir): +def test_propose_writes_a_loadable_proposed_rule_that_does_not_run_by_default( + tmp_path, data_dir +): reply_path = tmp_path / "review.json" reply_path.write_text(json.dumps(_reply())) result = propose(reply_path) @@ -157,7 +229,9 @@ def test_propose_writes_a_loadable_proposed_rule_that_does_not_run_by_default(tm def test_propose_rejects_a_rule_whose_examples_do_not_behave(tmp_path): reply = _reply() - reply["proposed_rules"][0]["example_good"] = "Do it.\n\nArgs:\n x: the thing\n" # fires on the good one + reply["proposed_rules"][0]["example_good"] = ( + "Do it.\n\nArgs:\n x: the thing\n" # fires on the good one + ) reply_path = tmp_path / "review.json" reply_path.write_text(json.dumps(reply)) result = propose(reply_path, overlay=tmp_path / "overlay")