622 lines
26 KiB
Python
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
|