Files
soleprint/soleprint/atlas2/docgen/cli/emit.py

253 lines
9.8 KiB
Python

"""
python3 -m docgen emit auto <ir.json> -o DIR whatever the structure asks for
python3 -m docgen emit dot <ir.json> [-o out.svg|.dot]
python3 -m docgen emit erd <ir.json> [-o out.svg]
python3 -m docgen emit index <ir.json> [-o out.md|.json]
python3 -m docgen emit minimap <ir.json> [-o out.svg] [--scale 0.55]
python3 -m docgen emit notebook <ir.json> [-o out.ipynb] [--overlay f.json]
python3 -m docgen emit site <ir.json> -o DIR
python3 -m docgen emit explore <ir.json> -o DIR
IR -> an artifact. Every emitter validates its input first, so a broken document
is reported here rather than drawn wrong. `--style` and `--theme` apply to every
emitter that draws.
"""
import argparse
import json
import re
import sys
from pathlib import Path
from .common import PROG, Abort, emit as write, load_style, read_ir, style_args
def _parser(name: str, *, styled: bool = True, output_help: str = "") -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog=f"{PROG} emit {name}")
p.add_argument("ir", type=Path)
p.add_argument("--output", "-o", type=Path, help=output_help or None)
if styled:
style_args(p)
return p
def _size(svg: str) -> str:
m = re.search(r'width="(\d+)pt" height="(\d+)pt"', svg)
return f"{m.group(1)}x{m.group(2)}" if m else ""
def auto(argv) -> int:
p = _parser("auto", output_help="Directory to write into.")
p.add_argument("--force", help="Use this emitter regardless of what fits.")
args = p.parse_args(argv)
from ..emitters.auto import draw
data, style = read_ir(args.ir), load_style(args.style, args.theme)
drawn = draw(data, style, force=args.force)
print(f" {drawn['verdict']['kind']:<8} -> {drawn['emitter']}")
print(f" {drawn['why']}")
if drawn["content"] is None:
raise Abort(drawn["why"])
out_dir = args.output or Path(".")
path = out_dir / f"{args.ir.stem}{drawn['suffix']}"
write(drawn["content"], path, f" {path}")
return 0
def dot(argv) -> int:
p = _parser("dot", output_help="Write here. .dot or .svg by suffix.")
p.add_argument("--max-depth", type=int, default=None)
p.add_argument("--quiet", "-q", action="store_true", help="Do not warn about shape.")
args = p.parse_args(argv)
from ..emitters.dot import RenderError, emit, render
from ..ops import shape
data, style = read_ir(args.ir), load_style(args.style, args.theme)
# Aspect ratio is a property of the graph, not of the renderer: a layered
# engine puts one dependency level in one row, so the widest level is the
# width. Say so before writing the file, because the alternative is finding
# out from a 13671pt image — and the fix is never a layout flag, it is a
# smaller question.
if not args.quiet:
sh = shape(data)
if sh["widest_level"] > 20 or sh["nodes"] > 60:
est = sh["widest_level"] / max(sh["levels"], 1)
print(f" note: {sh['nodes']} nodes, {sh['levels']} levels, widest level "
f"{sh['widest_level']} — this will render roughly {est:.0f}:1.\n"
" Around 20 nodes is where it stops being a diagram. Try "
"`view --split`,\n `--around <id> --hops 2`, or `--subtree <id>`. "
"Layout flags will not fix it.", file=sys.stderr)
if sh["isolated"] > sh["nodes"] // 3:
print(f" {sh['isolated']} of {sh['nodes']} nodes have no edges; they are "
"laid out side by side.", file=sys.stderr)
dot_text = emit(data, style, max_depth=args.max_depth)
if not args.output or args.output.suffix == ".dot":
write(dot_text, args.output, f" {args.style}/{style.theme:6} {args.output}")
return 0
try:
rendered = render(dot_text, fmt=args.output.suffix.lstrip(".") or "svg")
except RenderError as e:
raise Abort(str(e)) from None
write(rendered, args.output, f" {args.style}/{style.theme:6} {args.output}")
return 0
def erd(argv) -> int:
args = _parser("erd").parse_args(argv)
from ..emitters.erd import emit
data, style = read_ir(args.ir), load_style(args.style, args.theme)
try:
svg = emit(data, style)
except ValueError as e:
raise Abort(str(e)) from None
write(svg, args.output, f" erd/{style.theme:6} {args.output} {_size(svg)}")
return 0
def index(argv) -> int:
p = _parser("index", styled=False,
output_help=".md for the document, .json for a sidebar.")
p.add_argument("--title", default="")
args = p.parse_args(argv)
from ..emitters.index import to_markdown, to_sidebar
data = read_ir(args.ir)
if args.output and args.output.suffix == ".json":
text = json.dumps(to_sidebar(data), indent=2) + "\n"
else:
text = to_markdown(data, title=args.title)
write(text, args.output, f" index {args.output}")
return 0
def minimap(argv) -> int:
p = _parser("minimap")
p.add_argument("--scale", type=float, default=0.55, help="Pixels per source line.")
p.add_argument("--width", type=int, default=1180, help="Wrap a shelf past this.")
args = p.parse_args(argv)
from ..emitters.minimap import emit
data, style = read_ir(args.ir), load_style(args.style, args.theme)
try:
svg = emit(data, style, scale=args.scale, target_width=args.width)
except ValueError as e:
raise Abort(str(e)) from None
write(svg, args.output, f" minimap {args.output} {_size(svg)}")
return 0
def notebook(argv) -> int:
p = _parser("notebook", styled=False)
p.add_argument("--overlay", type=Path, help="Hand-written additions, re-applied.")
p.add_argument("--spec-out", type=Path, help="Write the generated spec too.")
p.add_argument("--scaffold", type=Path, help="Write a blank overlay and stop.")
p.add_argument("--base-url", default="https://api.example.invalid")
args = p.parse_args(argv)
from ..emitters.notebook import emit
from ..notebook import dump, from_ir, merge, scaffold
base = from_ir(read_ir(args.ir), base_url=args.base_url)
if args.scaffold:
dump(scaffold(base), args.scaffold)
print(f" overlay {args.scaffold} {len(base['steps'])} step(s), none filled in")
return 0
overlay = None
if args.overlay:
if args.overlay.exists():
overlay = json.loads(args.overlay.read_text())
else:
print(f" note: no overlay at {args.overlay} — generating the base only",
file=sys.stderr)
spec, drift = merge(base, overlay)
for d in drift:
# The base moved under the overlay. Worth saying out loud; not a reason
# to refuse to build the document.
print(f" drift: {d}", file=sys.stderr)
if args.spec_out:
dump(spec, args.spec_out)
print(f" spec {args.spec_out}")
text = emit(spec)
cells = len(json.loads(text)["cells"])
extra = f", {len(drift)} drift" if drift else ""
write(text, args.output,
f" notebook {args.output} {len(spec['steps'])} steps, {cells} cells{extra}")
return 0
def site(argv) -> int:
p = argparse.ArgumentParser(prog=f"{PROG} emit site")
p.add_argument("ir", type=Path)
p.add_argument("--output", "-o", type=Path, required=True, help="Directory.")
style_args(p)
p.add_argument("--title", default="")
p.add_argument("--no-graph", action="store_true")
args = p.parse_args(argv)
from ..emitters.auto import draw
from ..emitters.site import write as site_write
data, style = read_ir(args.ir), load_style(args.style, args.theme)
args.output.mkdir(parents=True, exist_ok=True)
graph_name = None
if not args.no_graph:
# Whatever the structure asks for, so the page carries the right picture
# — the same `draw()` the book and `emit auto` use.
drawn = draw(data, style)
if drawn["content"] is not None and drawn["suffix"] == ".svg":
graph_name = "graph.svg"
write(drawn["content"], args.output / graph_name)
else:
print(f" note: {drawn['verdict']['kind']}{drawn['why']}", file=sys.stderr)
print(" no diagram on the page; the index is the artifact", file=sys.stderr)
for f in site_write(data, style, args.output, graph=graph_name, title=args.title):
print(f" site {f}")
if graph_name:
print(f" site {args.output / graph_name}")
return 0
def explore(argv) -> int:
p = argparse.ArgumentParser(prog=f"{PROG} emit explore")
p.add_argument("ir", type=Path)
p.add_argument("--output", "-o", type=Path, required=True, help="Directory.")
style_args(p)
p.add_argument("--scale", type=float, default=0.55)
p.add_argument("--width", type=int, default=1100)
p.add_argument("--hops", type=int, default=1)
p.add_argument("--title", default="")
args = p.parse_args(argv)
from ..emitters.explore import write as explore_write
data, style = read_ir(args.ir), load_style(args.style, args.theme)
try:
path = explore_write(data, style, args.output, scale=args.scale, width=args.width,
hops=args.hops, title=args.title)
except ValueError as e:
raise Abort(str(e)) from None
graphs = args.output / "graphs"
count = len(list(graphs.glob("*.svg"))) if graphs.exists() else 0
print(f" explore {path} {count} neighbourhood diagram(s)")
return 0
EMITTERS = {"auto": auto, "dot": dot, "erd": erd, "index": index, "minimap": minimap,
"notebook": notebook, "site": site, "explore": explore}
def main(argv) -> int:
if not argv or argv[0] in ("-h", "--help"):
print(__doc__.strip("\n"))
return 0 if argv else 2
if argv[0] not in EMITTERS:
raise Abort(f"no emitter {argv[0]!r} — have: {', '.join(EMITTERS)}")
return EMITTERS[argv[0]](argv[1:])