Files
soleprint/soleprint/atlas2/docgen/book/config.py

622 lines
26 KiB
Python

"""
A run file: every book a project builds, so a rebuild is one command.
python3 -m docgen run docgen.toml
python3 -m docgen run docgen.toml --only station --only shop
python3 -m docgen run docgen.toml --list # what would run, resolved
Books are rebuilt many times — after a merge, before a release, whenever the
source moves — and each one is a source, an output directory and a handful of
options. Typed out every time, those drift: one run excludes `migrations`, the
next forgets. Written down once, the rebuild is exact.
## The shape
# docgen.toml
reference = "../soleprint" # optional; $DOCGEN_REFERENCE wins if set
[defaults]
out = "out/book" # each book goes to <out>/<name>
style = "lucid"
theme = "dark"
exclude = ["migrations", "tests"]
[[book]]
name = "station"
root = "../soleprint/station" # a tree; reader = "python" by default
[[book]]
name = "orders-api"
openapi = "specs/orders.yaml"
overlay = "overlays/orders.json"
out = "/srv/docs/orders" # overrides <defaults.out>/<name>
Each book names **exactly one** source — `root`, `schema`, `openapi` or `har` —
the same choice the `book` command makes you take.
## Three rules, each one a thing that went wrong somewhere else
**Paths are relative to the run file, not to wherever you ran the command.** A
run file is kept beside the project it describes; if its paths meant different
things from different directories, the same file would build different books.
**A book's value replaces the default, for every key.** Including `exclude`,
which is the one where merging looks tempting. One rule nobody has to remember
beats a clever one somebody has to look up.
**Unknown keys are refused, not ignored.** `exlude = [...]` silently doing
nothing is how a rebuild quietly starts reading `node_modules`. The style loader
takes the same line (requirements R30).
## Why TOML
It is read by the stdlib (`tomllib`), it allows comments — and a run file is
hand-written, so the reason a book excludes something belongs next to the
exclusion — and it is already the format of the `pyproject.toml` beside it. The
IR and style files stay JSON, because those are data that tools write; this is
configuration that people write.
"""
import os
import re
from dataclasses import dataclass, field
from pathlib import Path
try:
import tomllib
except ModuleNotFoundError: # pragma: no cover - Python < 3.11
tomllib = None
SOURCES = {"root": "python", "schema": "db", "openapi": "openapi", "har": "usage"}
READERS = ("python", "code")
TOP_KEYS = {"reference", "defaults", "book"}
DEFAULT_KEYS = {"out", "style", "theme", "exclude", "reader", "notes"}
BOOK_KEYS = {"name", "out", "style", "theme", "exclude", "reader", "overlay", "notes", *SOURCES}
# A book name becomes a directory name and a slug, so it is held to what is safe
# as both — no separators, nothing that means something to a shell.
NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
class ConfigError(ValueError):
"""A run file that cannot be run. Carries every problem, not the first."""
def __init__(self, path, problems: list[str]):
self.path, self.problems = path, problems
super().__init__(f"{path}: {len(problems)} problem(s)\n " + "\n ".join(problems))
@dataclass
class Entry:
"""One book, fully resolved — nothing relative, nothing defaulted later."""
name: str
kind: str # python | code | db | openapi | usage
source: Path
out: Path
style: str = "lucid"
theme: str | None = None
overlay: Path | None = None
exclude: tuple = ()
notes: Path | None = None
def line(self) -> str:
return f"{self.name:<18} {self.kind:<8} {self.source} -> {self.out}"
@dataclass
class RunFile:
path: Path
books: list[Entry] = field(default_factory=list)
reference: Path | None = None
def select(self, names) -> list[Entry]:
"""The named books, in run-file order. An unknown name is an error."""
if not names:
return list(self.books)
known = {b.name for b in self.books}
unknown = [n for n in names if n not in known]
if unknown:
raise ConfigError(self.path, [
f"no book named {n!r} — have: {', '.join(sorted(known))}" for n in unknown
])
wanted = set(names)
return [b for b in self.books if b.name in wanted]
def _path(value, base: Path) -> Path:
# Normalised lexically, so `--list` shows `atlas2/out` rather than
# `atlas2/docgen/../out`. Not resolved: a symlinked project should be named
# the way its owner names it.
p = Path(str(value)).expanduser()
return Path(os.path.normpath(p if p.is_absolute() else base / p))
def _within(inner: Path, outer: Path) -> bool:
inner, outer = inner.resolve(), outer.resolve()
return inner == outer or outer in inner.parents
def _read(path: Path) -> dict:
if tomllib is None:
raise ConfigError(path, ["run files need Python 3.11+ (tomllib)"])
try:
return tomllib.loads(path.read_text())
except OSError as e:
raise ConfigError(path, [f"cannot read: {e}"]) from None
except tomllib.TOMLDecodeError as e:
raise ConfigError(path, [f"not valid TOML: {e}"]) from None
def load(path):
"""Read and resolve a run file of either shape. Raises ConfigError listing
every problem.
Returns a `RunFile` for a `[[book]]` list, or a `ProjectFile` for a project.
A file with both is refused: which one it meant is exactly what nobody should
have to guess.
"""
path = Path(path)
data = _read(path)
if "project" in data and "book" in data:
raise ConfigError(path, [
"this file has both [project] and [[book]] — use one shape per file: "
"[project] for one document built from several sources, [[book]] for a "
"list of independent books"])
if "project" in data:
return load_project(path, data)
return _load_books(path, data)
def _load_books(path: Path, data: dict) -> "RunFile":
base = path.resolve().parent
problems: list[str] = []
for key in sorted(set(data) - TOP_KEYS):
problems.append(f"unknown top-level key {key!r} — have: {', '.join(sorted(TOP_KEYS))}")
defaults = data.get("defaults") or {}
if not isinstance(defaults, dict):
problems.append("[defaults] must be a table")
defaults = {}
for key in sorted(set(defaults) - DEFAULT_KEYS):
problems.append(f"[defaults] has unknown key {key!r} — have: "
f"{', '.join(sorted(DEFAULT_KEYS))}")
reference = None
if "reference" in data:
reference = _path(data["reference"], base)
if not reference.is_dir():
problems.append(f"reference {reference} is not a directory")
raw_books = data.get("book") or []
if not isinstance(raw_books, list) or not raw_books:
problems.append("no books — add at least one [[book]] table")
raw_books = []
books: list[Entry] = []
for i, raw in enumerate(raw_books):
where = f"book[{i}]"
if not isinstance(raw, dict):
problems.append(f"{where} must be a table")
continue
name = raw.get("name")
if isinstance(name, str):
where = f"book {name!r}"
if not isinstance(name, str) or not NAME.match(name):
problems.append(f"{where} needs a name of letters, digits, '.', '_' or '-'")
continue
for key in sorted(set(raw) - BOOK_KEYS):
problems.append(f"{where} has unknown key {key!r} — have: "
f"{', '.join(sorted(BOOK_KEYS))}")
named = [k for k in SOURCES if k in raw]
if len(named) != 1:
problems.append(
f"{where} must name exactly one source ({', '.join(SOURCES)}); "
f"it names {', '.join(named) if named else 'none'}"
)
continue
source_key = named[0]
source = _path(raw[source_key], base)
def pick(key, default=None):
# A book's value replaces the default, for every key.
return raw[key] if key in raw else defaults.get(key, default)
reader = pick("reader", "python")
if source_key == "root":
if reader not in READERS:
problems.append(f"{where}: reader must be one of {READERS}, got {reader!r}")
continue
kind = reader
if not source.is_dir():
problems.append(f"{where}: root {source} is not a directory")
else:
if "reader" in raw:
problems.append(f"{where}: reader only applies to root, not {source_key}")
kind = SOURCES[source_key]
if not source.is_file():
problems.append(f"{where}: {source_key} {source} is not a file")
if "out" in raw:
out = _path(raw["out"], base)
else:
out = _path(defaults.get("out", "out"), base) / name
overlay = _path(raw["overlay"], base) if "overlay" in raw else None
if overlay is not None and not overlay.is_file():
problems.append(f"{where}: overlay {overlay} is not a file")
exclude = pick("exclude", [])
if not isinstance(exclude, list) or not all(isinstance(x, str) for x in exclude):
problems.append(f"{where}: exclude must be a list of directory names")
exclude = []
elif source_key != "root":
# Only an exclude the book sets itself is a mistake. One inherited
# from [defaults] is meant for the trees and simply does not apply —
# refusing it would make a shared default impossible to write.
if "exclude" in raw:
problems.append(f"{where}: exclude only applies to a root, not {source_key}")
exclude = []
# Writing a book inside the tree it reads means the next run reads the
# last run's output. That is a rebuild that changes on every rebuild.
if source_key == "root" and source.is_dir() and _within(out, source):
problems.append(f"{where}: out {out} is inside the tree it reads ({source})")
notes = _path(pick("notes", "notes"), base)
books.append(Entry(
name=name, kind=kind, source=source, out=out,
style=pick("style", "lucid"), theme=pick("theme"),
overlay=overlay, exclude=tuple(exclude), notes=notes,
))
seen_names, seen_outs = {}, {}
for b in books:
if b.name in seen_names:
problems.append(f"book {b.name!r} is listed twice")
seen_names[b.name] = b
key = b.out.resolve()
if key in seen_outs:
# Two books into one directory: the second clears the first on every
# run, and nothing says so.
problems.append(f"books {seen_outs[key]!r} and {b.name!r} both write to {b.out}")
seen_outs[key] = b.name
if problems:
raise ConfigError(path, problems)
return RunFile(path=path, books=books, reference=reference)
def apply_reference(runfile: RunFile) -> str | None:
"""Point the one seam at the run file's reference — unless the caller's
environment already does. The caller's env beats the file, which is the
precedence rig uses for every setting it has.
"""
from .. import reference as ref
if runfile.reference is None or os.environ.get(ref.ENV_VAR):
return None
os.environ[ref.ENV_VAR] = str(runfile.reference)
return str(runfile.reference)
def _checks(directory, quiet: bool) -> list[str]:
import contextlib
import io
from . import checks as checks_mod
report = checks_mod.Report()
loaded = checks_mod.Loaded(directory)
sink = io.StringIO() if quiet else None
with contextlib.redirect_stdout(sink) if sink else contextlib.nullcontext():
checks_mod.generated(loaded, report)
checks_mod.custom(loaded, report)
return list(report.failed)
def run(runfile, names=None, *, check: bool = False, quiet: bool = True):
"""Build every selected book, in order. One failure never stops the rest.
Returns one result per book, plus one per output for a project:
{"name", "out", "ok", "error", "lost": [claims], "checks_failed": [names]}
"""
if isinstance(runfile, ProjectFile):
return run_project(runfile, names, check=check, quiet=quiet)
from .build import run as build
apply_reference(runfile)
results = []
for entry in runfile.select(names):
result = {"name": entry.name, "out": str(entry.out), "ok": False,
"error": None, "lost": [], "checks_failed": []}
try:
overlay = None
if entry.overlay is not None:
from ..notebook import spec as spec_mod
overlay = spec_mod.load(entry.overlay)
book = build(entry.kind, entry.source, entry.out, slug=entry.name,
style=entry.style, theme=entry.theme, exclude=entry.exclude,
overlay=overlay, notes=entry.notes, quiet=quiet)
result["lost"] = [r["claim"] for r in book.compare() if not r["ok"]]
except Exception as e: # noqa: BLE001 - one bad book must not cost the run
result["error"] = f"{type(e).__name__}: {e}"
results.append(result)
continue
if check:
result["checks_failed"] = _checks(entry.out, quiet)
result["ok"] = not result["lost"] and not result["checks_failed"]
results.append(result)
return results
# -- the project shape ----------------------------------------------------------
#
# One file per project, the shape a model writes unprompted:
#
# [project] name, title, notes, build, reference
# [extract.<kind>] one book per table; [[extract.<kind>]] for several
# [ops] applied to every book's view, before paging
# [emit.<output>] notebook, minisite — each assembles every book
#
# It compiles to the same Document a [[book]] entry does, so everything after
# loading is one code path.
PROJECT_KEYS = {"name", "title", "notes", "build", "reference"}
PROJECT_TOP = {"project", "extract", "ops", "emit"}
EXTRACT_KEYS = {
"python": {"name", "root", "exclude", "overlay"},
"code": {"name", "root", "ext", "exclude", "overlay"},
"db": {"name", "schema", "overlay"},
"openapi": {"name", "spec", "overlay"},
"usage": {"name", "har", "overlay"},
}
EXTRACT_SOURCE = {"python": "root", "code": "root", "db": "schema",
"openapi": "spec", "usage": "har"}
OPS_KEYS = {"drop_stdlib", "drop_builtins", "drop_external", "only", "drop"}
EMITTERS = {"notebook", "minisite"}
# Outputs that were renamed, with their new name. Refused with the rename rather
# than accepted quietly, so a run file says what it does.
RENAMED_EMITTERS = {"site": "minisite"}
EMIT_KEYS = {"output", "style", "theme", "width"}
# Keys a model reaches for that docgen deliberately does not do, with what to do
# instead. Refused with the reason rather than with a bare "unknown key", because
# the reason is the useful half of the refusal.
NOT_SUPPORTED = {
("db", "url"): (
"docgen reads a schema dump and never connects to a database — and a URL in "
"a committed file is a credential in a committed file. Write the schema with "
"`modelgen from-db`, then set `schema = \"path/to/schema.json\"`"
),
}
@dataclass
class ProjectFile:
path: Path
document: object # book.document.Document
reference: Path | None = None
@property
def books(self):
return self.document.parts
def load_project(path: Path, data: dict) -> ProjectFile:
from ..extractors.code import LANGUAGES
from .document import Document, Output, Part
base = path.resolve().parent
problems: list[str] = []
for key in sorted(set(data) - PROJECT_TOP):
problems.append(f"unknown top-level key {key!r} — a project file has: "
f"{', '.join(sorted(PROJECT_TOP))}")
project = data.get("project") or {}
for key in sorted(set(project) - PROJECT_KEYS):
problems.append(f"[project] has unknown key {key!r} — have: {', '.join(sorted(PROJECT_KEYS))}")
name = project.get("name")
if not isinstance(name, str) or not NAME.match(name):
problems.append("[project] needs a name of letters, digits, '.', '_' or '-'")
name = "project"
title = project.get("title") or name
notes = _path(project.get("notes", "notes"), base)
build_root = _path(project.get("build", ".docgen"), base)
reference = _path(project["reference"], base) if "reference" in project else None
if reference is not None and not reference.is_dir():
problems.append(f"[project] reference {reference} is not a directory")
# -- extract ----------------------------------------------------------------
extract = data.get("extract") or {}
if not isinstance(extract, dict) or not extract:
problems.append("no sources — add at least one [extract.<kind>] table "
f"({', '.join(sorted(EXTRACT_KEYS))})")
extract = {}
parts, names = [], set()
for kind, tables in extract.items():
if kind not in EXTRACT_KEYS:
problems.append(f"[extract.{kind}]: no reader named {kind!r} — have: "
f"{', '.join(sorted(EXTRACT_KEYS))}")
continue
tables = tables if isinstance(tables, list) else [tables]
for i, raw in enumerate(tables):
where = f"[extract.{kind}]" + (f"[{i}]" if len(tables) > 1 else "")
if not isinstance(raw, dict):
problems.append(f"{where} must be a table")
continue
for key in sorted(set(raw) - EXTRACT_KEYS[kind]):
reason = NOT_SUPPORTED.get((kind, key))
problems.append(f"{where} {key}: {reason}" if reason else
f"{where} has unknown key {key!r} — have: "
f"{', '.join(sorted(EXTRACT_KEYS[kind]))}")
book = raw.get("name") or (kind if len(tables) == 1 else None)
if not isinstance(book, str) or not NAME.match(book):
problems.append(f"{where} needs a name when there are several "
f"[[extract.{kind}]] tables")
continue
if book in names:
problems.append(f"{where}: a book named {book!r} already exists — set `name`")
continue
names.add(book)
src_key = EXTRACT_SOURCE[kind]
if src_key not in raw:
problems.append(f"{where} needs `{src_key}`")
continue
source = _path(raw[src_key], base)
if src_key == "root":
if not source.is_dir():
problems.append(f"{where}: root {source} is not a directory")
elif not source.is_file():
problems.append(f"{where}: {src_key} {source} is not a file")
exclude = raw.get("exclude", [])
if not isinstance(exclude, list) or not all(isinstance(x, str) for x in exclude):
problems.append(f"{where}: exclude must be a list of directory names")
exclude = []
ext = raw.get("ext")
if ext is not None:
if not isinstance(ext, list) or not all(isinstance(x, str) for x in ext):
problems.append(f"{where}: ext must be a list like [\".cs\", \".ts\"]")
else:
missing = [x for x in ext if x not in LANGUAGES]
if missing:
problems.append(
f"{where}: no grammar for {', '.join(missing)} — code reads "
f"{', '.join(sorted(LANGUAGES))}. Shell, YAML and SQL have no "
"reader yet; leave them out rather than expect them in the book")
overlay = None
if "overlay" in raw:
overlay_path = _path(raw["overlay"], base)
if not overlay_path.is_file():
problems.append(f"{where}: overlay {overlay_path} is not a file")
else:
import json
overlay = json.loads(overlay_path.read_text())
out = build_root / name / "books" / book
if src_key == "root" and source.is_dir() and _within(out, source):
problems.append(f"{where}: build directory {out} is inside the tree it reads "
f"({source}) — set [project] build outside it")
parts.append(Part(name=book, kind=kind, source=source, out=out,
exclude=tuple(exclude), overlay=overlay,
suffixes=tuple(ext) if ext else None))
# -- ops --------------------------------------------------------------------
ops = data.get("ops") or {}
for key in sorted(set(ops) - OPS_KEYS):
problems.append(f"[ops] has unknown key {key!r} — have: {', '.join(sorted(OPS_KEYS))}")
for key in ("only", "drop"):
if key in ops and not (isinstance(ops[key], list)
and all(isinstance(x, str) for x in ops[key])):
problems.append(f"[ops] {key} must be a list of kinds")
for part in parts:
part.ops = {k: v for k, v in ops.items() if k in OPS_KEYS}
# -- emit -------------------------------------------------------------------
emit = data.get("emit") or {}
outputs = []
if not emit:
problems.append("no outputs — add [emit.notebook] or [emit.minisite] with an `output`")
for emitter, raw in emit.items():
where = f"[emit.{emitter}]"
if emitter in RENAMED_EMITTERS:
problems.append(f"{where} is now [emit.{RENAMED_EMITTERS[emitter]}] — rename the table")
continue
if emitter not in EMITTERS:
problems.append(f"{where}: no output named {emitter!r} — have: "
f"{', '.join(sorted(EMITTERS))}")
continue
if not isinstance(raw, dict):
problems.append(f"{where} must be a table")
continue
for key in sorted(set(raw) - EMIT_KEYS):
problems.append(f"{where} has unknown key {key!r} — have: {', '.join(sorted(EMIT_KEYS))}")
if "output" not in raw:
problems.append(f"{where} needs `output`")
continue
width = raw.get("width")
if width is not None and (not isinstance(width, int) or width < 200):
problems.append(f"{where}: width must be a number of pixels, 200 or more")
width = None
target = _path(raw["output"], base)
for part in parts:
if part.kind in ("python", "code") and part.source.is_dir() \
and _within(target, part.source):
problems.append(f"{where}: output {target} is inside the tree book "
f"{part.name!r} reads — the next run would read it")
outputs.append(Output(emitter=emitter, path=target, style=raw.get("style", "lucid"),
theme=raw.get("theme"), width=width))
if problems:
raise ConfigError(path, problems)
doc = Document(name=name, title=title, build=build_root / name, parts=parts,
outputs=outputs, notes=notes)
return ProjectFile(path=path, document=doc, reference=reference)
def select_parts(project: ProjectFile, names) -> list:
if not names:
return list(project.document.parts)
known = {p.name for p in project.document.parts}
unknown = [n for n in names if n not in known]
if unknown:
raise ConfigError(project.path, [
f"no book named {n!r} — have: {', '.join(sorted(known))}" for n in unknown])
return [p for p in project.document.parts if p.name in set(names)]
def run_project(project: ProjectFile, names=None, *, check: bool = False, quiet: bool = True):
"""Build a project's document: every book, then every output.
`--only` narrows the books that go into this build's outputs, which is useful
while writing notes for one of them — the outputs are then that subset, and
say so in their ledger.
"""
from dataclasses import replace
from .document import build
apply_reference(project)
doc = replace(project.document, parts=select_parts(project, names), books=[], problems=[])
results = []
try:
build(doc, quiet=quiet)
except Exception as e: # noqa: BLE001 - reported, not raised
return [{"name": doc.name, "out": str(doc.build), "ok": False,
"error": f"{type(e).__name__}: {e}", "lost": [], "checks_failed": []}]
for part in doc.parts:
if part.name in doc.failed:
results.append({"name": part.name, "out": str(part.out), "ok": False,
"error": doc.failed[part.name], "lost": [], "checks_failed": []})
for book in doc.books:
lost = [r["claim"] for r in book.compare() if not r["ok"]]
failed = _checks(book.out, quiet) if check else []
results.append({"name": book.slug, "out": str(book.out), "ok": not lost and not failed,
"error": None, "lost": lost, "checks_failed": failed})
if check and doc.books:
from . import checks as checks_mod
failed = checks_mod.document_failures(doc.build, quiet=quiet)
else:
failed = []
# A failed book is already its own row; the document row carries what else
# went wrong — notes that drifted, an overlay that no longer matches.
rest = [p for p in doc.problems if not p.startswith("book ") or "was not built" not in p]
results.append({"name": f"{doc.name} (document)", "out": str(doc.build),
"ok": not rest and not failed and bool(doc.books), "error": None,
"lost": rest, "checks_failed": failed})
return results