add Docusaurus docs generation: API reference, CLI, REST API, citations, DAG viz
Wire cli/docs.py build/serve/generate commands to run extraction scripts and Docusaurus. Add extract_cli.py (27 CLI docs from typer help), extract_openapi.py (8 REST API docs from FastAPI schema), extract_citations.py (14 pipeline pages with bib references), extract_dag.py (pipeline + table lineage Mermaid diagrams). Closes #26, #27, #28, #29, #30.
This commit is contained in:
126
docs/scripts/extract_citations.py
Normal file
126
docs/scripts/extract_citations.py
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
"""Extract Zotero-sourced regulatory citations for pipeline docs.
|
||||||
|
|
||||||
|
For each pipeline, finds bib items tagged with the pipeline's module
|
||||||
|
namespace and generates a citations page with linked references.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
uv run python docs/scripts/extract_citations.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path("src").resolve()))
|
||||||
|
|
||||||
|
OUT_DIR = Path("docs/docs/pipelines")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
from conf import path as _conf_path
|
||||||
|
|
||||||
|
bib_db = _conf_path("db.bib")
|
||||||
|
if not bib_db.exists():
|
||||||
|
print("bib.sqlite not found, skipping citations")
|
||||||
|
return
|
||||||
|
|
||||||
|
from aco.pipe import registry
|
||||||
|
from bib.store import Store
|
||||||
|
|
||||||
|
store = Store(str(bib_db))
|
||||||
|
|
||||||
|
if OUT_DIR.exists():
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
shutil.rmtree(OUT_DIR)
|
||||||
|
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
cat_json = OUT_DIR / "_category_.json"
|
||||||
|
cat_json.write_text('{"label": "Pipelines", "position": 12}\n')
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
# Overview page
|
||||||
|
overview = [
|
||||||
|
"---\ntitle: Pipelines\nsidebar_position: 1\n---\n",
|
||||||
|
"# Pipelines\n",
|
||||||
|
"| Pipeline | Steps | Description |",
|
||||||
|
"|----------|-------|-------------|",
|
||||||
|
]
|
||||||
|
for name, pipeline in sorted(registry.items()):
|
||||||
|
overview.append(f"| [{name}](./{name}) | {len(pipeline)} | |")
|
||||||
|
overview.append("")
|
||||||
|
(OUT_DIR / "index.md").write_text("\n".join(overview))
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
# Per-pipeline pages with citations
|
||||||
|
all_items = store.list_items()
|
||||||
|
|
||||||
|
for pos, (name, pipeline) in enumerate(sorted(registry.items()), start=2):
|
||||||
|
lines = [
|
||||||
|
f"---\ntitle: {name}\nsidebar_position: {pos}\n---\n",
|
||||||
|
f"# {name}\n",
|
||||||
|
f"**Steps:** {len(pipeline)}\n",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Step list
|
||||||
|
lines.append("## Steps\n")
|
||||||
|
for i, expr in enumerate(pipeline.exprs, 1):
|
||||||
|
doc = expr.fn.__doc__ or ""
|
||||||
|
doc_line = doc.strip().split("\n")[0] if doc else ""
|
||||||
|
lines.append(f"{i}. **{expr.name}**{f' — {doc_line}' if doc_line else ''}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Find citations: items tagged with module:{pipeline_name} or
|
||||||
|
# related tags
|
||||||
|
module_tag = f"module:{name}"
|
||||||
|
cited_items = [
|
||||||
|
item for item in all_items if module_tag in item.tags
|
||||||
|
]
|
||||||
|
|
||||||
|
# Also check for aco-related tags
|
||||||
|
if not cited_items:
|
||||||
|
aco_tag = "module:aco"
|
||||||
|
cited_items = [
|
||||||
|
item for item in all_items if aco_tag in item.tags
|
||||||
|
][:5] # Limit to top 5 general references
|
||||||
|
|
||||||
|
if cited_items:
|
||||||
|
lines.append("## Regulatory References\n")
|
||||||
|
for item in cited_items:
|
||||||
|
title = item.title
|
||||||
|
url = item.url
|
||||||
|
date = item.date_published or ""
|
||||||
|
institution = item.institution or ""
|
||||||
|
|
||||||
|
if url:
|
||||||
|
lines.append(f"- [{title}]({url})")
|
||||||
|
else:
|
||||||
|
lines.append(f"- {title}")
|
||||||
|
|
||||||
|
meta_parts = []
|
||||||
|
if institution:
|
||||||
|
meta_parts.append(institution)
|
||||||
|
if date:
|
||||||
|
meta_parts.append(date)
|
||||||
|
if meta_parts:
|
||||||
|
lines.append(f" *{' | '.join(meta_parts)}*")
|
||||||
|
|
||||||
|
abstract = item.abstract or ""
|
||||||
|
if abstract:
|
||||||
|
short = abstract[:200] + "..." if len(abstract) > 200 else abstract
|
||||||
|
lines.append(f" {short}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
out_path = OUT_DIR / f"{name}.md"
|
||||||
|
out_path.write_text("\n".join(lines))
|
||||||
|
count += 1
|
||||||
|
print(f" {name} -> {out_path}")
|
||||||
|
|
||||||
|
print(f"\nDone: {count} pipeline docs written to {OUT_DIR}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
101
docs/scripts/extract_cli.py
Normal file
101
docs/scripts/extract_cli.py
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
"""Extract CLI usage docs from typer commands.
|
||||||
|
|
||||||
|
Walks the CLI app tree, invokes ``--help`` for each command, and
|
||||||
|
writes Markdown files into ``docs/docs/cli/``.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
uv run python docs/scripts/extract_cli.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
|
# Ensure src/ is on the path
|
||||||
|
sys.path.insert(0, str(Path("src").resolve()))
|
||||||
|
|
||||||
|
from cli import app # noqa: E402
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
|
||||||
|
OUT_DIR = Path("docs/docs/cli")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_commands(group_name: str = "") -> list[tuple[str, list[str]]]:
|
||||||
|
"""Walk the typer app tree and return (name, cmd_path) pairs."""
|
||||||
|
commands: list[tuple[str, list[str]]] = []
|
||||||
|
|
||||||
|
# Top-level help
|
||||||
|
commands.append(("stack", []))
|
||||||
|
|
||||||
|
# Registered sub-commands / groups
|
||||||
|
for info in app.registered_commands:
|
||||||
|
name = info.name or info.callback.__name__
|
||||||
|
commands.append((name, [name]))
|
||||||
|
|
||||||
|
for group in app.registered_groups:
|
||||||
|
grp_name = group.name or ""
|
||||||
|
if not grp_name:
|
||||||
|
continue
|
||||||
|
commands.append((grp_name, [grp_name]))
|
||||||
|
|
||||||
|
# Walk sub-commands within the group
|
||||||
|
grp_app = group.typer_instance
|
||||||
|
if grp_app:
|
||||||
|
for sub_info in grp_app.registered_commands:
|
||||||
|
sub_name = sub_info.name or sub_info.callback.__name__
|
||||||
|
commands.append(
|
||||||
|
(f"{grp_name}-{sub_name}", [grp_name, sub_name])
|
||||||
|
)
|
||||||
|
for sub_group in grp_app.registered_groups:
|
||||||
|
sg_name = sub_group.name or ""
|
||||||
|
if sg_name:
|
||||||
|
commands.append(
|
||||||
|
(f"{grp_name}-{sg_name}", [grp_name, sg_name])
|
||||||
|
)
|
||||||
|
|
||||||
|
return commands
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
if OUT_DIR.exists():
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
shutil.rmtree(OUT_DIR)
|
||||||
|
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Category metadata for sidebar
|
||||||
|
cat_json = OUT_DIR / "_category_.json"
|
||||||
|
cat_json.write_text('{"label": "CLI Reference", "position": 10}\n')
|
||||||
|
|
||||||
|
commands = _get_commands()
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
for slug, cmd_path in commands:
|
||||||
|
result = runner.invoke(app, [*cmd_path, "--help"])
|
||||||
|
if result.exit_code != 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
help_text = result.output.strip()
|
||||||
|
title = f"stack {' '.join(cmd_path)}" if cmd_path else "stack"
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"---\ntitle: {title}\nsidebar_position: {count + 1}\n---\n",
|
||||||
|
f"# `{title}`\n",
|
||||||
|
f"```\n{help_text}\n```\n",
|
||||||
|
]
|
||||||
|
|
||||||
|
out_path = OUT_DIR / f"{slug}.md"
|
||||||
|
out_path.write_text("\n".join(lines))
|
||||||
|
count += 1
|
||||||
|
print(f" {title} -> {out_path}")
|
||||||
|
|
||||||
|
print(f"\nDone: {count} CLI docs written to {OUT_DIR}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
92
docs/scripts/extract_dag.py
Normal file
92
docs/scripts/extract_dag.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
"""Generate pipeline DAG visualization for docs.
|
||||||
|
|
||||||
|
Creates a Mermaid diagram of the full lineage graph and writes it
|
||||||
|
as a Docusaurus MDX page.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
uv run python docs/scripts/extract_dag.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path("src").resolve()))
|
||||||
|
|
||||||
|
OUT_DIR = Path("docs/docs")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
from aco.lake.lineage import build_lineage
|
||||||
|
|
||||||
|
graph = build_lineage()
|
||||||
|
mermaid = graph.to_mermaid()
|
||||||
|
|
||||||
|
# Also build a simplified pipeline-level DAG from PIPELINES registry
|
||||||
|
from aco.pipe import registry
|
||||||
|
|
||||||
|
pipeline_names = sorted(registry.keys())
|
||||||
|
|
||||||
|
# Build inter-pipeline dependency edges from lineage
|
||||||
|
pipeline_deps: dict[str, set[str]] = {n: set() for n in pipeline_names}
|
||||||
|
for table, deps in graph.table_edges.items():
|
||||||
|
table_pipeline = table.split(".")[0] if "." in table else table
|
||||||
|
for dep in deps:
|
||||||
|
dep_pipeline = dep.split(".")[0] if "." in dep else dep
|
||||||
|
if (
|
||||||
|
table_pipeline != dep_pipeline
|
||||||
|
and table_pipeline in pipeline_deps
|
||||||
|
and dep_pipeline in pipeline_deps
|
||||||
|
):
|
||||||
|
pipeline_deps[table_pipeline].add(dep_pipeline)
|
||||||
|
|
||||||
|
# Pipeline-level Mermaid
|
||||||
|
pipeline_mermaid_lines = ["graph LR"]
|
||||||
|
for name in pipeline_names:
|
||||||
|
safe = name.replace("-", "_")
|
||||||
|
pipeline_mermaid_lines.append(f" {safe}[{name}]")
|
||||||
|
for name, deps in sorted(pipeline_deps.items()):
|
||||||
|
safe_name = name.replace("-", "_")
|
||||||
|
for dep in sorted(deps):
|
||||||
|
safe_dep = dep.replace("-", "_")
|
||||||
|
pipeline_mermaid_lines.append(f" {safe_dep} --> {safe_name}")
|
||||||
|
pipeline_mermaid = "\n".join(pipeline_mermaid_lines)
|
||||||
|
|
||||||
|
content = f"""---
|
||||||
|
title: Pipeline DAG
|
||||||
|
sidebar_position: 13
|
||||||
|
---
|
||||||
|
|
||||||
|
# Pipeline DAG
|
||||||
|
|
||||||
|
## Pipeline Dependencies
|
||||||
|
|
||||||
|
High-level view of how pipelines depend on each other.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
{pipeline_mermaid}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Full Table Lineage
|
||||||
|
|
||||||
|
Detailed table-level dependency graph across all pipelines.
|
||||||
|
{len(graph.table_edges)} tables, {sum(len(v) for v in graph.table_edges.values())} edges.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
{mermaid}
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
out_path = OUT_DIR / "dag.md"
|
||||||
|
out_path.write_text(content)
|
||||||
|
print(f"DAG visualization -> {out_path}")
|
||||||
|
print(
|
||||||
|
f" {len(pipeline_names)} pipelines, "
|
||||||
|
f"{len(graph.table_edges)} tables"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
171
docs/scripts/extract_openapi.py
Normal file
171
docs/scripts/extract_openapi.py
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
"""Extract REST API docs from FastAPI OpenAPI schema.
|
||||||
|
|
||||||
|
Reads the OpenAPI JSON from the FastAPI app and generates Markdown
|
||||||
|
files for each route group into ``docs/docs/rest-api/``.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
uv run python docs/scripts/extract_openapi.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path("src").resolve()))
|
||||||
|
|
||||||
|
OUT_DIR = Path("docs/docs/rest-api")
|
||||||
|
|
||||||
|
|
||||||
|
def _type_str(schema: dict) -> str:
|
||||||
|
"""Convert a JSON Schema type to a readable string."""
|
||||||
|
if "$ref" in schema:
|
||||||
|
return schema["$ref"].rsplit("/", 1)[-1]
|
||||||
|
if "anyOf" in schema:
|
||||||
|
parts = [_type_str(s) for s in schema["anyOf"]]
|
||||||
|
return " | ".join(parts)
|
||||||
|
t = schema.get("type", "object")
|
||||||
|
if t == "array":
|
||||||
|
items = schema.get("items", {})
|
||||||
|
return f"list[{_type_str(items)}]"
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
def _render_endpoint(method: str, path: str, spec: dict) -> str:
|
||||||
|
"""Render a single endpoint to markdown."""
|
||||||
|
lines = []
|
||||||
|
summary = spec.get("summary", "")
|
||||||
|
lines.append(f"### `{method.upper()} {path}`\n")
|
||||||
|
if summary:
|
||||||
|
lines.append(f"{summary}\n")
|
||||||
|
|
||||||
|
# Parameters
|
||||||
|
params = spec.get("parameters", [])
|
||||||
|
if params:
|
||||||
|
lines.append("**Parameters:**\n")
|
||||||
|
lines.append("| Name | In | Type | Required |")
|
||||||
|
lines.append("|------|-----|------|----------|")
|
||||||
|
for p in params:
|
||||||
|
schema = p.get("schema", {})
|
||||||
|
lines.append(
|
||||||
|
f"| `{p['name']}` | {p.get('in', '')} "
|
||||||
|
f"| {_type_str(schema)} | {p.get('required', False)} |"
|
||||||
|
)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Request body
|
||||||
|
body = spec.get("requestBody", {})
|
||||||
|
if body:
|
||||||
|
content = body.get("content", {})
|
||||||
|
for ct, ct_spec in content.items():
|
||||||
|
schema = ct_spec.get("schema", {})
|
||||||
|
lines.append(f"**Request body** (`{ct}`):\n")
|
||||||
|
lines.append(f"Type: `{_type_str(schema)}`\n")
|
||||||
|
|
||||||
|
# Responses
|
||||||
|
responses = spec.get("responses", {})
|
||||||
|
if responses:
|
||||||
|
lines.append("**Responses:**\n")
|
||||||
|
lines.append("| Status | Description |")
|
||||||
|
lines.append("|--------|-------------|")
|
||||||
|
for code, resp in sorted(responses.items()):
|
||||||
|
desc = resp.get("description", "")
|
||||||
|
lines.append(f"| {code} | {desc} |")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
from api.server import app # noqa: E402
|
||||||
|
|
||||||
|
schema = app.openapi()
|
||||||
|
|
||||||
|
if OUT_DIR.exists():
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
shutil.rmtree(OUT_DIR)
|
||||||
|
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Category metadata
|
||||||
|
cat_json = OUT_DIR / "_category_.json"
|
||||||
|
cat_json.write_text('{"label": "REST API", "position": 11}\n')
|
||||||
|
|
||||||
|
# Write full OpenAPI JSON
|
||||||
|
(OUT_DIR / "openapi.json").write_text(json.dumps(schema, indent=2))
|
||||||
|
|
||||||
|
# Group endpoints by first tag
|
||||||
|
groups: dict[str, list[tuple[str, str, dict]]] = {}
|
||||||
|
for path, methods in schema.get("paths", {}).items():
|
||||||
|
for method, spec in methods.items():
|
||||||
|
if method in ("parameters", "servers", "summary"):
|
||||||
|
continue
|
||||||
|
tags = spec.get("tags", ["default"])
|
||||||
|
tag = tags[0] if tags else "default"
|
||||||
|
groups.setdefault(tag, []).append((method, path, spec))
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
# Overview page
|
||||||
|
overview_lines = [
|
||||||
|
"---\ntitle: REST API Overview\nsidebar_position: 1\n---\n",
|
||||||
|
"# REST API\n",
|
||||||
|
f"**Title:** {schema.get('info', {}).get('title', 'Stack API')}\n",
|
||||||
|
f"**Version:** {schema.get('info', {}).get('version', '0.1.0')}\n",
|
||||||
|
"## Endpoints\n",
|
||||||
|
]
|
||||||
|
for tag, endpoints in sorted(groups.items()):
|
||||||
|
overview_lines.append(f"- [{tag}](./{tag}) ({len(endpoints)} endpoints)")
|
||||||
|
overview_lines.append(
|
||||||
|
"\n\nFull OpenAPI spec: [openapi.json](./openapi.json)\n"
|
||||||
|
)
|
||||||
|
(OUT_DIR / "index.md").write_text("\n".join(overview_lines))
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
# Per-group pages
|
||||||
|
for pos, (tag, endpoints) in enumerate(sorted(groups.items()), start=2):
|
||||||
|
lines = [
|
||||||
|
f"---\ntitle: {tag}\nsidebar_position: {pos}\n---\n",
|
||||||
|
f"# {tag}\n",
|
||||||
|
]
|
||||||
|
for method, path, spec in endpoints:
|
||||||
|
lines.append(_render_endpoint(method, path, spec))
|
||||||
|
|
||||||
|
out_path = OUT_DIR / f"{tag}.md"
|
||||||
|
out_path.write_text("\n".join(lines))
|
||||||
|
count += 1
|
||||||
|
print(f" {tag} -> {out_path}")
|
||||||
|
|
||||||
|
# Schemas section
|
||||||
|
schemas = schema.get("components", {}).get("schemas", {})
|
||||||
|
if schemas:
|
||||||
|
schema_lines = [
|
||||||
|
"---\ntitle: Schemas\nsidebar_position: 99\n---\n",
|
||||||
|
"# Schemas\n",
|
||||||
|
]
|
||||||
|
for name, s in sorted(schemas.items()):
|
||||||
|
schema_lines.append(f"## `{name}`\n")
|
||||||
|
desc = s.get("description", "")
|
||||||
|
if desc:
|
||||||
|
schema_lines.append(f"{desc}\n")
|
||||||
|
props = s.get("properties", {})
|
||||||
|
if props:
|
||||||
|
schema_lines.append("| Field | Type | Required |")
|
||||||
|
schema_lines.append("|-------|------|----------|")
|
||||||
|
required = set(s.get("required", []))
|
||||||
|
for field, fspec in props.items():
|
||||||
|
schema_lines.append(
|
||||||
|
f"| `{field}` | {_type_str(fspec)} "
|
||||||
|
f"| {'yes' if field in required else ''} |"
|
||||||
|
)
|
||||||
|
schema_lines.append("")
|
||||||
|
|
||||||
|
(OUT_DIR / "schemas.md").write_text("\n".join(schema_lines))
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
print(f"\nDone: {count} REST API docs written to {OUT_DIR}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -6,6 +6,19 @@ const sidebars = {
|
|||||||
type: "autogenerated",
|
type: "autogenerated",
|
||||||
dirName: "api",
|
dirName: "api",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: "autogenerated",
|
||||||
|
dirName: "cli",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "autogenerated",
|
||||||
|
dirName: "rest-api",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "autogenerated",
|
||||||
|
dirName: "pipelines",
|
||||||
|
},
|
||||||
|
"dag",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,20 +2,100 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
|
|
||||||
app = typer.Typer(no_args_is_help=True)
|
app = typer.Typer(no_args_is_help=True)
|
||||||
|
|
||||||
|
DOCS_DIR = Path(__file__).resolve().parent.parent.parent / "docs"
|
||||||
|
SCRIPTS_DIR = DOCS_DIR / "scripts"
|
||||||
|
|
||||||
|
|
||||||
|
def _run_script(name: str) -> bool:
|
||||||
|
"""Run a docs generation script via uv. Returns True on success."""
|
||||||
|
script = SCRIPTS_DIR / name
|
||||||
|
if not script.exists():
|
||||||
|
typer.echo(f" skip: {name} not found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
typer.echo(f" running {name}...")
|
||||||
|
|
||||||
|
# griffe is needed for extract_docs
|
||||||
|
extras = ["--with", "griffe"] if name == "extract_docs.py" else []
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
["uv", "run", *extras, "python", str(script)],
|
||||||
|
cwd=str(DOCS_DIR.parent),
|
||||||
|
)
|
||||||
|
return result.returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def _generate() -> None:
|
||||||
|
"""Run all documentation generation scripts."""
|
||||||
|
typer.echo("Generating documentation content...")
|
||||||
|
scripts = [
|
||||||
|
"extract_docs.py",
|
||||||
|
"extract_cli.py",
|
||||||
|
"extract_openapi.py",
|
||||||
|
"extract_citations.py",
|
||||||
|
"extract_dag.py",
|
||||||
|
"export_library.py",
|
||||||
|
]
|
||||||
|
ok = 0
|
||||||
|
for s in scripts:
|
||||||
|
if _run_script(s):
|
||||||
|
ok += 1
|
||||||
|
typer.echo(f"Generated: {ok}/{len(scripts)} scripts succeeded")
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
def build() -> None:
|
def build(
|
||||||
|
skip_generate: bool = typer.Option(
|
||||||
|
False,
|
||||||
|
"--skip-generate",
|
||||||
|
help="Skip content generation, only run Docusaurus build.",
|
||||||
|
),
|
||||||
|
) -> None:
|
||||||
"""Generate documentation site."""
|
"""Generate documentation site."""
|
||||||
typer.echo("docs build [stub]")
|
if not skip_generate:
|
||||||
|
_generate()
|
||||||
|
|
||||||
|
typer.echo("Building Docusaurus site...")
|
||||||
|
npm = "npm.cmd" if sys.platform == "win32" else "npm"
|
||||||
|
result = subprocess.run(
|
||||||
|
[npm, "run", "build"],
|
||||||
|
cwd=str(DOCS_DIR),
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
typer.echo("Docusaurus build failed. Is node/npm installed?")
|
||||||
|
typer.echo(f" docs dir: {DOCS_DIR}")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
typer.echo("docs build complete")
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
def serve(
|
def serve(
|
||||||
port: int = typer.Option(8000, help="Port to serve on."),
|
port: int = typer.Option(3000, help="Port to serve on."),
|
||||||
|
skip_generate: bool = typer.Option(
|
||||||
|
False, "--skip-generate", help="Skip content generation."
|
||||||
|
),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Serve documentation locally."""
|
"""Serve documentation locally with hot reload."""
|
||||||
typer.echo(f"docs serve: port={port} [stub]")
|
if not skip_generate:
|
||||||
|
_generate()
|
||||||
|
|
||||||
|
typer.echo(f"Starting Docusaurus dev server on port={port}...")
|
||||||
|
npm = "npm.cmd" if sys.platform == "win32" else "npm"
|
||||||
|
subprocess.run(
|
||||||
|
[npm, "start", "--", "--port", str(port)],
|
||||||
|
cwd=str(DOCS_DIR),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def generate() -> None:
|
||||||
|
"""Generate documentation content without building the site."""
|
||||||
|
_generate()
|
||||||
|
|||||||
@@ -129,17 +129,17 @@ class TestValidate:
|
|||||||
|
|
||||||
|
|
||||||
class TestDocs:
|
class TestDocs:
|
||||||
def test_docs_build(self) -> None:
|
def test_docs_build_help(self) -> None:
|
||||||
result = runner.invoke(app, ["docs", "build"])
|
result = runner.invoke(app, ["docs", "build", "--help"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "docs build" in result.output
|
assert "skip-generate" in result.output
|
||||||
|
|
||||||
def test_docs_serve(self) -> None:
|
def test_docs_serve_help(self) -> None:
|
||||||
result = runner.invoke(app, ["docs", "serve"])
|
result = runner.invoke(app, ["docs", "serve", "--help"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "docs serve" in result.output
|
assert "port" in result.output
|
||||||
|
|
||||||
def test_docs_serve_custom_port(self) -> None:
|
def test_docs_generate_help(self) -> None:
|
||||||
result = runner.invoke(app, ["docs", "serve", "--port", "9000"])
|
result = runner.invoke(app, ["docs", "generate", "--help"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "port=9000" in result.output
|
assert "Generate" in result.output
|
||||||
|
|||||||
Reference in New Issue
Block a user