Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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.

Expand All @@ -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
Expand All @@ -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** (`<!-- generated by epythet -->`, `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).
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<package>.md`, and `objects.inv`.

Every site also says where it came from. A small line at the bottom of the landing page reads `built <UTC time> from <commit> (<branch>) · <package> <version> · 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.

<!-- epythet:agentic-readme:start -->
Expand Down
21 changes: 21 additions & 0 deletions actions/publish-github-pages/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions epythet/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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,
Expand Down
70 changes: 70 additions & 0 deletions epythet/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
``<package>.md`` aggregate get a pointer to that file.
"""

from __future__ import annotations
Expand All @@ -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):
Expand Down Expand Up @@ -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
Expand All @@ -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
23 changes: 23 additions & 0 deletions epythet/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("."):
Expand Down Expand Up @@ -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.
Expand Down
14 changes: 12 additions & 2 deletions epythet/confgen.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down
16 changes: 16 additions & 0 deletions epythet/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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

Expand All @@ -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())
Expand Down Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions epythet/data/skills/epythet-setup/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ agent_outputs = true # llms.txt, .md twins, <link rel="alternate"> 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

Expand All @@ -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

Expand Down Expand Up @@ -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 `<name>.md` exist.
3. Landing page shows the README; sidebar shows the module tree; `docsrc/_build/html/llms.txt`, `<name>.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.
Loading
Loading