diff --git a/soleprint/atlas2/docgen/.gitignore b/soleprint/atlas2/docgen/.gitignore
index b455f56..f58baf9 100644
--- a/soleprint/atlas2/docgen/.gitignore
+++ b/soleprint/atlas2/docgen/.gitignore
@@ -2,3 +2,4 @@
out/
__pycache__/
*.pyc
+.venv/
diff --git a/soleprint/atlas2/docgen/Makefile b/soleprint/atlas2/docgen/Makefile
index 038b330..ac3585d 100644
--- a/soleprint/atlas2/docgen/Makefile
+++ b/soleprint/atlas2/docgen/Makefile
@@ -1,31 +1,33 @@
# docgen — code to diagram, and to everything else the IR can feed.
#
# Derived from where this file sits, so the folder can be copied anywhere and
-# renamed and still work. The logic lives in the Python, never here.
+# renamed and still work. The logic lives in the Python, never here: every target
+# is one line calling `python3 -m docgen `.
#
+# make sync create .venv with every optional group (uv)
# make book SRC=../station the whole operation, measured at both ends
+# make run CONFIG=docgen.toml every book a run file lists
# make check prove docgen, on a tree it builds itself
# make check BOOK=out/book/x prove one book — its own level
# make ir SRC=../station extract -> out/ir.json (one step, on its own)
-# make graph out/ir.json -> out/graph.svg
-# make index out/ir.json -> out/index.md
# make self docgen's book of itself, then check it
# make doctor what this machine has
#
-# Every target below is one step of a book and still works alone — that is the
-# property the book spine exists to preserve, not to replace.
+# Every step target still works alone — that is the property the book spine
+# exists to preserve, not to replace. The steps compose by hand too:
#
-# The pipeline is three commands and they compose, which is the point:
-#
-# python3 -m docgen.extractors.python --root SRC -o ir.json
-# python3 -m docgen.ops ir.json --overview -o view.json
-# python3 -m docgen.emitters dot view.json -o graph.svg --theme dark
+# python3 -m docgen extract python --root SRC -o ir.json
+# python3 -m docgen view ir.json --overview -o view.json
+# python3 -m docgen emit dot view.json -o graph.svg --theme dark
HERE := $(patsubst %/,%,$(dir $(abspath $(lastword $(MAKEFILE_LIST)))))
PKG := $(notdir $(HERE))
PARENT := $(patsubst %/,%,$(dir $(HERE)))
-PY ?= python3
-RUN := PYTHONPATH=$(PARENT) $(PY) -m
+VENV_PY := $(HERE)/.venv/bin/python
+# The synced environment when there is one (`make sync`), the system Python when
+# there is not — so `make check` still works with nothing installed at all.
+PY ?= $(if $(wildcard $(VENV_PY)),$(VENV_PY),python3)
+CLI := PYTHONPATH=$(PARENT) $(PY) -m $(PKG)
OUT ?= $(HERE)/out
SRC ?=
@@ -34,20 +36,25 @@ OPENAPI ?=
HAR ?=
STYLE ?= lucid
THEME ?=
-DEPTH ?= 2
SCALE ?= 0.55
BOOK ?=
SLUG ?=
# NOT `LANG`: that is the shell's locale variable, so `?=` inherits
-# en_US.UTF-8 from the environment and --lang rejects it.
+# en_US.UTF-8 from the environment and --reader rejects it.
READER ?= python
OVERLAY ?=
+CONFIG ?= docgen.toml
+ONLY ?=
+CHECK ?=
+comma := ,
THEME_ARG := $(if $(THEME),--theme $(THEME))
+STYLE_ARGS := --style $(STYLE) $(THEME_ARG)
SLUG_ARG := $(if $(SLUG),--slug $(SLUG))
OVER_ARG := $(if $(OVERLAY),--overlay $(OVERLAY))
+ONLY_ARGS := $(foreach n,$(subst $(comma), ,$(ONLY)),--only $(n))
-.PHONY: help book check ir db code graph index site minimap explore docs view self doctor clean
+.PHONY: help sync lock book run check ir db code view graph index site minimap explore docs self doctor clean
help: ## List every target
@echo "docgen — static analysis of a tree, and the artifacts that fall out of it"
@@ -61,62 +68,81 @@ help: ## List every target
@echo " BOOK=/path where a book goes, and which book to check"
@echo " READER=python|code ast, or tree-sitter SLUG=name what to call the book"
@echo " OVERLAY=overlay.json hand-written notebook additions, re-applied every build"
- @echo " DEPTH=2 how deep to draw"
+ @echo " CONFIG=docgen.toml a run file ONLY=a,b just these books"
+ @echo " CHECK=1 with run: each book's own level after building it"
@echo
@echo " Three levels of test, by what they assert about:"
@echo " make doctor the machine. Never fails."
@echo " make check docgen. Exits 1."
@echo " make check BOOK= that book. Exits 1."
-check: ## Prove docgen (or one book, with BOOK=)
- @if [ -n "$(BOOK)" ]; then \
- $(RUN) $(PKG).book.checks "$(BOOK)"; \
- else \
- $(PY) $(HERE)/selftest.py; \
- fi
+sync: ## Create .venv with every optional group, from uv.lock
+ @command -v uv >/dev/null || { echo "Error: uv is not installed — docgen still runs on the system python3" >&2; exit 1; }
+ @cd $(HERE) && uv sync --all-groups
+
+lock: ## Re-resolve uv.lock after editing pyproject.toml
+ @cd $(HERE) && uv lock
book: ## SRC (or SCHEMA/OPENAPI/HAR) -> one operation, measured at both ends
@test -n "$(SRC)$(SCHEMA)$(OPENAPI)$(HAR)" \
|| { echo "Error: set SRC=/path/to/tree (or SCHEMA=, OPENAPI=, HAR=)" >&2; exit 1; }
- @$(RUN) $(PKG).book \
- $(if $(SRC),--root "$(SRC)" --lang $(READER)) \
+ @$(CLI) book \
+ $(if $(SRC),--root "$(SRC)" --reader $(READER)) \
$(if $(SCHEMA),--schema "$(SCHEMA)") \
$(if $(OPENAPI),--openapi "$(OPENAPI)") \
$(if $(HAR),--har "$(HAR)") \
-o "$(if $(BOOK),$(BOOK),$(OUT)/book)" \
- --style $(STYLE) $(THEME_ARG) $(SLUG_ARG) $(OVER_ARG)
+ $(STYLE_ARGS) $(SLUG_ARG) $(OVER_ARG)
+
+run: ## CONFIG (a run file) -> every book it lists; ONLY=a,b for some
+ @$(CLI) run "$(CONFIG)" $(ONLY_ARGS) $(if $(CHECK),--check)
+
+check: ## Prove docgen (or one book, with BOOK=)
+ @$(CLI) check $(if $(BOOK),"$(BOOK)")
ir: ## Extract SRC into OUT/ir.json
@test -n "$(SRC)" || { echo "Error: set SRC=/path/to/tree" >&2; exit 1; }
- @mkdir -p $(OUT)
- @$(RUN) $(PKG).extractors.python --root "$(SRC)" -o $(OUT)/ir.json
- @$(RUN) $(PKG).ir $(OUT)/ir.json
+ @$(CLI) extract python --root "$(SRC)" -o $(OUT)/ir.json
+ @$(CLI) validate $(OUT)/ir.json
+
+code: ## Extract C#/TypeScript from SRC (needs the `code` group)
+ @test -n "$(SRC)" || { echo "Error: set SRC=/path/to/tree" >&2; exit 1; }
+ @$(CLI) extract code --root "$(SRC)" -o $(OUT)/ir.json
+ @$(CLI) validate $(OUT)/ir.json
db: ## Extract a graphgen-compatible SCHEMA into OUT/ir.json
@test -n "$(SCHEMA)" || { echo "Error: set SCHEMA=/path/to/schema.json" >&2; exit 1; }
- @mkdir -p $(OUT)
- @$(RUN) $(PKG).extractors --schema "$(SCHEMA)" -o $(OUT)/ir.json
- @$(RUN) $(PKG).ir $(OUT)/ir.json
+ @$(CLI) extract db --schema "$(SCHEMA)" -o $(OUT)/ir.json
+ @$(CLI) validate $(OUT)/ir.json
view: ## OUT/ir.json -> OUT/view.json, the default view for its source type
- @$(RUN) $(PKG).ops $(OUT)/ir.json --overview -o $(OUT)/view.json
+ @$(CLI) view $(OUT)/ir.json --overview -o $(OUT)/view.json
graph: view ## OUT/view.json -> whatever its structure asks for
- @$(RUN) $(PKG).emitters auto $(OUT)/view.json -o $(OUT) \
- --style $(STYLE) $(THEME_ARG)
+ @$(CLI) emit auto $(OUT)/view.json -o $(OUT) $(STYLE_ARGS)
+
+index: ## OUT/ir.json -> OUT/index.md and OUT/sidebar.json
+ @$(CLI) emit index $(OUT)/ir.json -o $(OUT)/index.md
+ @$(CLI) emit index $(OUT)/ir.json -o $(OUT)/sidebar.json
site: view ## OUT/view.json -> a self-contained docs site in OUT/site
- @$(RUN) $(PKG).emitters site $(OUT)/view.json -o $(OUT)/site \
- --style $(STYLE) $(THEME_ARG)
+ @$(CLI) emit site $(OUT)/view.json -o $(OUT)/site $(STYLE_ARGS)
@echo " open $(OUT)/site/index.html"
+minimap: ## OUT/ir.json -> OUT/minimap.svg — what is where, read from the colours
+ @$(CLI) emit minimap $(OUT)/ir.json -o $(OUT)/minimap.svg $(STYLE_ARGS) --scale $(SCALE)
+
+explore: ## OUT/ir.json -> OUT/explore/ — navigate on one side, explore on the other
+ @$(CLI) emit explore $(OUT)/ir.json -o $(OUT)/explore $(STYLE_ARGS) --scale $(SCALE)
+ @echo " open $(OUT)/explore/explore.html"
+
docs: ## Regenerate the figures in docs/ — docgen documented by docgen
- @mkdir -p docs/img
- @$(RUN) $(PKG).extractors.python --root $(HERE) -o /tmp/$(PKG)-docs.json >/dev/null
- @$(RUN) $(PKG).ops /tmp/$(PKG)-docs.json --overview -o /tmp/$(PKG)-docs-view.json >/dev/null
- @$(RUN) $(PKG).emitters dot /tmp/$(PKG)-docs-view.json -o docs/img/architecture.svg -q
- @$(RUN) $(PKG).emitters minimap /tmp/$(PKG)-docs.json -o docs/img/minimap.svg --scale 0.5 --width 860
- @$(RUN) $(PKG).emitters erd $(OUT)/ir.json -o docs/img/erd.svg 2>/dev/null \
+ @mkdir -p $(HERE)/docs/img
+ @$(CLI) extract python --root $(HERE) -o /tmp/$(PKG)-docs.json >/dev/null
+ @$(CLI) view /tmp/$(PKG)-docs.json --overview -o /tmp/$(PKG)-docs-view.json >/dev/null
+ @$(CLI) emit dot /tmp/$(PKG)-docs-view.json -o $(HERE)/docs/img/architecture.svg -q
+ @$(CLI) emit minimap /tmp/$(PKG)-docs.json -o $(HERE)/docs/img/minimap.svg --scale 0.5 --width 860
+ @$(CLI) emit erd $(OUT)/ir.json -o $(HERE)/docs/img/erd.svg 2>/dev/null \
|| echo " (erd figure kept — needs a schema IR at $(OUT)/ir.json to refresh)"
@PYTHONPATH=$(PARENT) $(PY) -c "from $(PKG).emitters.site import VIEWER, _slots, _fill; \
from $(PKG).style import Style; import pathlib; \
@@ -124,25 +150,6 @@ pathlib.Path('$(HERE)/docs/viewer.html').write_text( \
_fill(VIEWER.replace('__TITLE__', 'docgen docs'), _slots(Style.load('lucid'))))"
@echo " open $(HERE)/docs/index.html"
-explore: ## OUT/ir.json -> OUT/explore/ — navigate on one side, explore on the other
- @$(RUN) $(PKG).emitters explore $(OUT)/ir.json -o $(OUT)/explore \
- --style $(STYLE) $(THEME_ARG) --scale $(SCALE)
- @echo " open $(OUT)/explore/explore.html"
-
-minimap: ## OUT/ir.json -> OUT/minimap.svg — what is where, read from the colours
- @$(RUN) $(PKG).emitters minimap $(OUT)/ir.json -o $(OUT)/minimap.svg \
- --style $(STYLE) $(THEME_ARG) --scale $(SCALE)
-
-code: ## Extract C#/TypeScript from SRC (needs tree-sitter)
- @test -n "$(SRC)" || { echo "Error: set SRC=/path/to/tree" >&2; exit 1; }
- @mkdir -p $(OUT)
- @$(RUN) $(PKG).extractors code --root "$(SRC)" -o $(OUT)/ir.json
- @$(RUN) $(PKG).ir $(OUT)/ir.json
-
-index: ## OUT/ir.json -> OUT/index.md and OUT/sidebar.json
- @$(RUN) $(PKG).emitters index $(OUT)/ir.json -o $(OUT)/index.md
- @$(RUN) $(PKG).emitters index $(OUT)/ir.json -o $(OUT)/sidebar.json
-
self: ## docgen's book of the widest tree it can see, then check it
@$(eval SELF_SRC := $(shell PYTHONPATH=$(PARENT) $(PY) -c "from $(PKG) import reference; \
r = reference.root(); print(r if r else '$(HERE)')"))
@@ -153,18 +160,19 @@ r = reference.root(); print(r if r else '$(HERE)')"))
@$(MAKE) --no-print-directory check BOOK=$(OUT)/book/self
doctor: ## Report whether this machine can run it
- @printf 'python : '; $(PY) --version 2>&1 || echo MISSING
+ @printf 'python : %s ' '$(PY)'; $(PY) --version 2>&1 || echo MISSING
+ @printf 'uv : '; if ! command -v uv >/dev/null; then echo 'absent — fine; docgen runs on the system python3'; \
+ elif [ -x $(VENV_PY) ]; then echo "$$(uv --version), .venv synced"; \
+ else echo "$$(uv --version), .venv not synced — make sync for the optional groups"; fi
@printf 'dot : '; (dot -V 2>&1) || echo 'MISSING — sudo apt install graphviz (only to render)'
- @printf 'tree-sit : '; $(PY) -c 'import tree_sitter, tree_sitter_c_sharp, tree_sitter_typescript; print("ok — C# and TypeScript available")' 2>/dev/null || echo 'absent — Python only. pip install tree_sitter tree_sitter_c_sharp tree_sitter_typescript'
- @printf 'networkx : '; $(PY) -c 'import networkx; print(networkx.__version__ + " — for lab/ experiments")' 2>/dev/null || echo 'absent — only used in lab/'
- @printf 'package : %s (from %s)\n' '$(PKG)' '$(PARENT)'
+ @printf 'tree-sit : '; $(PY) -c 'import tree_sitter, tree_sitter_c_sharp, tree_sitter_typescript; print("ok — C# and TypeScript available")' 2>/dev/null || echo 'absent — Python only. make sync, or the `code` group'
+ @printf 'lxml : '; $(PY) -c 'import lxml; print("ok — theme harvesting available")' 2>/dev/null || echo 'absent — only used to harvest a theme'
@printf 'yaml : '; $(PY) -c 'import yaml; print("ok — needed only to read OpenAPI")' 2>/dev/null || echo 'absent — only used by the OpenAPI reader'
+ @printf 'networkx : '; $(PY) -c 'import networkx; print(networkx.__version__ + " — for lab/ experiments")' 2>/dev/null || echo 'absent — only used in lab/'
@printf 'reference: '; PYTHONPATH=$(PARENT) $(PY) -c "from $(PKG) import reference; print(reference.describe())"
-
- @printf 'styles : '; $(RUN) $(PKG).style 2>/dev/null \
- || $(RUN) $(PKG) 2>/dev/null \
- || PYTHONPATH=$(PARENT) $(PY) -c "from $(PKG).style import Style; print(', '.join(Style.available()))"
- @PYTHONPATH=$(PARENT) $(PY) -c "import $(PKG).ir, $(PKG).emitters.dot, $(PKG).ops" >/dev/null 2>&1 \
+ @printf 'package : %s (from %s)\n' '$(PKG)' '$(PARENT)'
+ @printf 'styles : '; PYTHONPATH=$(PARENT) $(PY) -c "from $(PKG).style import Style; print(', '.join(Style.available()))"
+ @PYTHONPATH=$(PARENT) $(PY) -c "import $(PKG).ir, $(PKG).emitters.dot, $(PKG).ops, $(PKG).cli" >/dev/null 2>&1 \
&& echo 'import : ok' || echo 'import : FAILED — is the folder intact?'
clean: ## Delete OUT. Nothing else is ever written to
diff --git a/soleprint/atlas2/docgen/README.md b/soleprint/atlas2/docgen/README.md
index 938444d..c07f87d 100644
--- a/soleprint/atlas2/docgen/README.md
+++ b/soleprint/atlas2/docgen/README.md
@@ -14,19 +14,68 @@ extractors/ → graph IR (JSON) → emitters/
```
```bash
+make sync # optional: .venv with every group, from uv.lock
make check # prove it, on a tree it builds itself
-make self # run the whole thing over soleprint
-make ir SRC=../../station/tools/histgen
-make index && make graph
+make book SRC=/path/to/repo # one whole operation, measured at both ends
+make run CONFIG=docgen.toml # every book a run file lists
make help
```
-Or as three composable commands, which is what the Makefile is wrapping:
+The full documentation is `docs/index.html` — open it in a browser.
+
+## Layout
+
+```
+cli/ the command line — every command, and nothing else
+book/ an operation: larder measure, steps, web output, run files
+extractors/ source -> IR, one per source type
+ir/ the contract: schema.json, the dataclasses, the validator
+ops/ IR -> a smaller IR
+emitters/ IR -> an artifact
+notebook/ the notebook spec, before it is an .ipynb
+style/ slots, themes, and harvesting a theme from real diagrams
+fixtures/ docgen's own test inputs
+lab/ sanctioned experiments; nothing imports it
+reference.py the one seam to the repo above, for reading OpenAPI
+```
+
+Library packages hold **no command-line code** — no argparse, no `__main__.py`,
+no `cli_dot.py` beside `dot.py`. It all lives in `cli/`, behind one entry point,
+and the selftest asserts it stays there.
+
+`pyproject.toml` declares no required dependency: the structural path is the
+stdlib, Python 3.11+. The optional groups (`code`, `openapi`, `harvest`, `lab`)
+are pinned in `uv.lock`; `[tool.uv] package = false`, as dataconvert does, since
+this is a folder run in place rather than something to install.
+
+## Run files
+
+```toml
+# docgen.toml — beside the project it describes; paths relative to this file
+[defaults]
+out = "out/book"
+
+[[book]]
+name = "station"
+root = "../soleprint/station"
+
+[[book]]
+name = "shop"
+schema = "schemas/shop.json"
+```
+
+`make run CONFIG=docgen.toml [ONLY=station] [CHECK=1]`. One failing book never
+stops the others; unknown keys are refused; a rebuild replaces the previous
+build's outputs and never touches a hand-written `checks.py`.
+`docgen.example.toml` runs against the shipped fixtures.
+
+Or as three composable commands, which is what the Makefile is wrapping — run
+from the directory above this one:
```bash
-python3 -m docgen.extractors.python --root SRC -o ir.json
-python3 -m docgen.ops ir.json --drop-stdlib -o view.json
-python3 -m docgen.emitters dot view.json -o graph.svg --theme dark
+python3 -m docgen extract python --root SRC -o ir.json
+python3 -m docgen view ir.json --drop-stdlib -o view.json
+python3 -m docgen emit dot view.json -o graph.svg --theme dark
```
## DOT collapses three concerns; this separates them
@@ -71,7 +120,7 @@ boundary, and it reads the field lists out of `schema.json` so the two cannot
drift.
```bash
-python3 -m docgen.ir ir.json
+python3 -m docgen validate ir.json
```
It catches what a schema cannot: an edge naming a node that does not exist, a
@@ -124,7 +173,7 @@ belongs to every consumer at once — the index, the diagram and the diff all wa
"just this subsystem, two hops out, without the stdlib".
```bash
-python3 -m docgen.ops ir.json --drop-stdlib --around docgen.ir --hops 2 -o view.json
+python3 -m docgen view ir.json --drop-stdlib --around docgen.ir --hops 2 -o view.json
```
`drop_stdlib`, `drop_external`, `only_kinds`, `drop_kinds`, `subtree`,
@@ -191,7 +240,9 @@ about either.
## Testing
```bash
-make check # 59 checks, offline, nothing installed
+make check # docgen's own suite: 287 after make sync, 272 with nothing
+make check BOOK=out/book/x # one book's own level — generated and hand-written checks
+make doctor # the machine; never fails
```
**Golden tests go on the IR, never on the SVG.** Graphviz measures label text
@@ -202,7 +253,7 @@ Self-hosting is the honest end-to-end check, and it is where the real bugs came
from — two name-resolution faults that no fixture had reached:
```bash
-make self # extract soleprint, and read out/index.md
+make self # docgen's book of the widest tree it can see, then its checks
```
## Where this sits
diff --git a/soleprint/atlas2/docgen/__main__.py b/soleprint/atlas2/docgen/__main__.py
new file mode 100644
index 0000000..7d7ba13
--- /dev/null
+++ b/soleprint/atlas2/docgen/__main__.py
@@ -0,0 +1,8 @@
+""" python3 -m docgen [args] — see cli/__init__.py for the commands."""
+
+import sys
+
+from .cli import main
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/soleprint/atlas2/docgen/book/build.py b/soleprint/atlas2/docgen/book/build.py
index 79fc92b..6df0abd 100644
--- a/soleprint/atlas2/docgen/book/build.py
+++ b/soleprint/atlas2/docgen/book/build.py
@@ -1,10 +1,10 @@
"""
Run a book: larder, the steps, the web output, the book measure.
- python3 -m docgen.book --root ../station -o out/book/station
+ python3 -m docgen book --root ../station -o out/book/station
Composes functions that already exist. Nothing here parses, lays out or styles
-anything — `ops.overview`, `emitters.dot`, `emitters.site` and `notebook.spec`
+anything — `ops.overview`, `emitters.auto.draw`, `emitters.site` and `notebook.spec`
do all of it, and this decides the order and writes the ledger. If this file
ever starts doing the work, the seam has moved to the wrong place.
@@ -218,6 +218,41 @@ def _load_cell(step_id: str, artifact: str) -> str:
return f'print(load("{artifact}") is not None)'
+# What a build writes into a book directory, and therefore what a rebuild may
+# remove first. Named, never globbed: a book directory also holds the hand-written
+# `checks.py` and `overlay.json`, and a misconfigured output path could point at
+# somebody's source tree — so nothing outside this list is ever deleted.
+GENERATED = ("book.json", "notebook.ipynb", "steps", "site", "explore")
+
+
+def clear(out) -> list[str]:
+ """Remove a previous build's outputs from `out`. Returns what was removed.
+
+ Without this, re-running a book leaves the last run's artifacts beside the new
+ ledger: a graph that is now `graph.md` keeps its old `graph.svg`, and the site
+ goes on showing it. The ledger would not list the stale file, so nothing would
+ say it was stale — which is the failure the book measure exists to prevent.
+
+ Only acts on a directory that already holds a `book.json`, i.e. one this code
+ wrote. Anything else is left exactly as found.
+ """
+ import shutil
+
+ out = Path(out)
+ if not (out / "book.json").is_file():
+ return []
+ removed = []
+ for name in GENERATED:
+ target = out / name
+ if target.is_dir():
+ shutil.rmtree(target)
+ removed.append(name)
+ elif target.is_file():
+ target.unlink()
+ removed.append(name)
+ return removed
+
+
def run(kind: str, source, out, *, slug: str | None = None, style: str = "lucid",
theme: str | None = None, exclude=(), overlay=None, quiet: bool = False) -> Book:
"""The whole book, in one process. Returns it; `book.json` is written."""
@@ -227,6 +262,7 @@ def run(kind: str, source, out, *, slug: str | None = None, style: str = "lucid"
out = Path(out)
slug = slug or Path(str(source)).name or "book"
+ clear(out)
steps_dir = out / "steps"
steps_dir.mkdir(parents=True, exist_ok=True)
@@ -262,32 +298,26 @@ def run(kind: str, source, out, *, slug: str | None = None, style: str = "lucid"
style_obj = Style.load(style, theme=theme)
- # The drawing, chosen by structure rather than by the caller. `classify`
- # already decided; this runs what it named.
+ # The drawing, chosen by structure rather than by the caller — the same
+ # `draw()` the `emit auto` and `emit site` commands use, so the three can
+ # never disagree about what a graph wants.
+ from ..emitters.auto import draw
+
+ drawn = draw(view, style_obj)
graph_rel = None
- if verdict["emitter"] == "erd":
- from ..emitters.erd import emit as erd_emit
- p = steps_dir / "graph.svg"
- p.write_text(erd_emit(view, style_obj))
- graph_rel, label = "steps/graph.svg", "drawn as an ERD"
- elif verdict["emitter"] == "index":
- from ..emitters.index import to_markdown
- p = steps_dir / "graph.md"
- p.write_text(to_markdown(view))
- graph_rel, label = None, "not a diagram — written as a list"
+ labels = {"erd": "drawn as an ERD", "dot": f"drawn as a {verdict['kind']}",
+ "index": "not a diagram — written as a list"}
+ if drawn["content"] is None:
+ book.step("graph", "not drawn", skipped=drawn["why"])
else:
- from ..emitters.dot import emit as dot_emit, render
- p = steps_dir / "graph.svg"
- try:
- p.write_bytes(render(dot_emit(view, style_obj,
- rankdir=(verdict.get("options") or {}).get("rankdir"))))
- graph_rel, label = "steps/graph.svg", f"drawn as a {verdict['kind']}"
- except Exception as e: # noqa: BLE001 - graphviz may not be installed
- p = None
- label = f"not drawn — {type(e).__name__}"
- book.step("graph", label, skipped=str(e)[:200])
- if p is not None:
- book.step("graph", label, p, note=verdict["why"])
+ p = steps_dir / f"graph{drawn['suffix']}"
+ if isinstance(drawn["content"], bytes):
+ p.write_bytes(drawn["content"])
+ else:
+ p.write_text(drawn["content"])
+ if drawn["suffix"] == ".svg":
+ graph_rel = f"steps/{p.name}"
+ book.step("graph", labels[drawn["emitter"]], p, note=drawn["why"])
from ..emitters.index import to_markdown, to_sidebar
p = steps_dir / "index.md"
diff --git a/soleprint/atlas2/docgen/book/checks.py b/soleprint/atlas2/docgen/book/checks.py
index 28d874e..8c69b78 100644
--- a/soleprint/atlas2/docgen/book/checks.py
+++ b/soleprint/atlas2/docgen/book/checks.py
@@ -1,7 +1,7 @@
"""
Does *this* book still hold — the third test level.
- python3 -m docgen.book.checks out/book/station
+ python3 -m docgen check out/book/station
docgen has three levels of test, and they differ by what they assert *about*.
The distinction is rig's, from `rig/ctrl/selftest.sh`, and it is worth keeping
@@ -68,7 +68,7 @@ class Loaded:
if not path.exists():
raise FileNotFoundError(
f"{path} does not exist — is {self.dir} a book directory?\n"
- "Build one with: python3 -m docgen.book --root -o "
+ "Build one with: python3 -m docgen book --root -o "
)
self.data = json.loads(path.read_text())
@@ -272,34 +272,3 @@ def _run_cells(cells: list, cwd: Path) -> tuple[int, str | None]:
finally:
os.chdir(previous)
return ran, failure
-
-
-def main(argv=None) -> int:
- import argparse
-
- p = argparse.ArgumentParser(
- prog="python3 -m docgen.book.checks",
- description="Check one book: the spine's assertions, then its own.",
- )
- p.add_argument("book", type=Path, help="A book directory (holding book.json).")
- p.add_argument("--only", choices=("generated", "custom"),
- help="Run one level rather than both.")
- args = p.parse_args(argv)
-
- try:
- book = Loaded(args.book)
- except (FileNotFoundError, json.JSONDecodeError) as e:
- print(f"Error: {e}", file=sys.stderr)
- return 1
-
- print(f"{book.dir} — {book.data.get('slug', '?')}")
- r = Report()
- if args.only != "custom":
- generated(book, r)
- if args.only != "generated":
- custom(book, r)
- return r.total()
-
-
-if __name__ == "__main__":
- sys.exit(main())
diff --git a/soleprint/atlas2/docgen/book/config.py b/soleprint/atlas2/docgen/book/config.py
new file mode 100644
index 0000000..e6391a6
--- /dev/null
+++ b/soleprint/atlas2/docgen/book/config.py
@@ -0,0 +1,327 @@
+"""
+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 /
+ 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 /
+
+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"}
+BOOK_KEYS = {"name", "out", "style", "theme", "exclude", "reader", "overlay", *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 = ()
+
+ 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 load(path) -> RunFile:
+ """Read and resolve a run file. Raises ConfigError listing every problem."""
+ path = Path(path)
+ if tomllib is None:
+ raise ConfigError(path, ["run files need Python 3.11+ (tomllib)"])
+ try:
+ data = 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
+
+ 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})")
+
+ books.append(Entry(
+ name=name, kind=kind, source=source, out=out,
+ style=pick("style", "lucid"), theme=pick("theme"),
+ overlay=overlay, exclude=tuple(exclude),
+ ))
+
+ 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 run(runfile: 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:
+ {"name", "out", "ok", "error", "lost": [claims], "checks_failed": [names]}
+ """
+ from . import checks as checks_mod
+ 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, 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:
+ import contextlib
+ import io
+
+ report = checks_mod.Report()
+ loaded = checks_mod.Loaded(entry.out)
+ 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)
+ result["checks_failed"] = list(report.failed)
+
+ result["ok"] = not result["lost"] and not result["checks_failed"]
+ results.append(result)
+ return results
diff --git a/soleprint/atlas2/docgen/cli/__init__.py b/soleprint/atlas2/docgen/cli/__init__.py
new file mode 100644
index 0000000..44f1a18
--- /dev/null
+++ b/soleprint/atlas2/docgen/cli/__init__.py
@@ -0,0 +1,75 @@
+"""
+The command line — every command docgen has, and nothing else.
+
+ python3 -m docgen [args]
+
+ book one operation, measured at both ends
+ run every book a run file lists
+ check docgen's own suite, or one book's own level
+ extract source -> ir.json
+ validate check an ir.json at the boundary
+ view ir.json -> a narrower ir.json
+ emit ir.json -> an artifact
+
+## Why all of it lives here
+
+The packages beside this one — `extractors/`, `ops/`, `emitters/`, `book/` — are
+library code, and they hold no command-line code at all: no argparse, no
+`__main__.py`, no `cli_dot.py` sitting next to `dot.py`. Two kinds of file in one
+folder makes every folder answer two questions, and the command-line half is the
+part that grows copies — each old `cli_*` re-implemented "read the IR, validate
+it, load the style" by hand.
+
+So the split is by kind rather than by feature: library in the packages, the
+command line here, and `selftest.py` asserts that nothing outside this package
+imports `argparse`, so it stays that way without anyone remembering to.
+
+Plain argparse and no scaffold, on purpose. docgen is copied out of the repo as a
+folder, and a shared CLI helper that lives in some other tool would tie the copy
+back to the repo.
+"""
+
+import sys
+
+# command -> (module in this package, one-line description)
+COMMANDS = {
+ "book": ("book", "one operation, measured at both ends"),
+ "run": ("run", "every book a run file lists"),
+ "check": ("check", "docgen's own suite, or one book's own level"),
+ "extract": ("extract", "source -> ir.json"),
+ "validate": ("validate", "check an ir.json at the boundary"),
+ "view": ("view", "ir.json -> a narrower ir.json"),
+ "emit": ("emit", "ir.json -> an artifact"),
+}
+
+
+def usage() -> str:
+ from .common import PROG
+
+ lines = [f"usage: {PROG} [args]", ""]
+ lines += [f" {name:<9} {desc}" for name, (_, desc) in COMMANDS.items()]
+ lines += ["", f" {PROG} --help for a command's options"]
+ return "\n".join(lines)
+
+
+def main(argv=None) -> int:
+ from importlib import import_module
+
+ from .common import Abort
+
+ argv = sys.argv[1:] if argv is None else list(argv)
+ if not argv or argv[0] in ("-h", "--help", "help"):
+ print(usage())
+ return 0 if argv else 2
+ name, rest = argv[0], argv[1:]
+ if name not in COMMANDS:
+ print(f"Error: no command {name!r}\n\n{usage()}", file=sys.stderr)
+ return 2
+
+ module = import_module(f".{COMMANDS[name][0]}", package=__name__)
+ try:
+ return module.main(rest)
+ except Abort as e:
+ # The only exit path for an expected failure: one line, stderr, exit 1.
+ print(f"Error: {e}", file=sys.stderr)
+ return 1
diff --git a/soleprint/atlas2/docgen/book/__main__.py b/soleprint/atlas2/docgen/cli/book.py
similarity index 57%
rename from soleprint/atlas2/docgen/book/__main__.py
rename to soleprint/atlas2/docgen/cli/book.py
index 159d94f..d78ca21 100644
--- a/soleprint/atlas2/docgen/book/__main__.py
+++ b/soleprint/atlas2/docgen/cli/book.py
@@ -1,43 +1,47 @@
-""" python3 -m docgen.book --root ../station -o out/book/station
+"""
+ python3 -m docgen book --root SRC -o out/book/name [--reader python|code]
+ python3 -m docgen book --schema schema.json -o DIR
+ python3 -m docgen book --openapi spec.yaml -o DIR
+ python3 -m docgen book --har session.har -o DIR
One book: larder measure, the steps, the web output, book measure. The step
artifacts land in `steps/` and are ordinary files — nothing here needs this
command to have been the thing that produced them.
+
+Exits 1 when the two ends do not reconcile. The book is still written — the
+evidence is the point — but a build that lost input should fail a pipeline
+rather than pass quietly.
"""
import argparse
-import sys
from pathlib import Path
-KINDS = ("python", "code", "db", "openapi", "usage")
+from .common import PROG, Abort, style_args
-def main(argv=None) -> int:
- p = argparse.ArgumentParser(
- prog="python3 -m docgen.book",
- description="Run one docgen operation, measured at both ends.",
- )
+def main(argv) -> int:
+ p = argparse.ArgumentParser(prog=f"{PROG} book",
+ description="Run one docgen operation, measured at both ends.")
src = p.add_mutually_exclusive_group(required=True)
- src.add_argument("--root", type=Path, help="A source tree (Python, or --lang code).")
+ src.add_argument("--root", type=Path, help="A source tree.")
src.add_argument("--schema", type=Path, help="A graphgen-compatible schema.json.")
src.add_argument("--openapi", type=Path, help="An OpenAPI document.")
src.add_argument("--har", type=Path, help="A HAR recording.")
- p.add_argument("--lang", choices=("python", "code"), default="python",
+ p.add_argument("--reader", choices=("python", "code"), default="python",
help="With --root: the stdlib ast reader, or tree-sitter. Default python.")
p.add_argument("--output", "-o", type=Path, required=True,
- help="The book directory. Created if absent.")
+ help="The book directory. Created if absent; a previous build is replaced.")
p.add_argument("--slug", help="What to call it. Defaults to the source's name.")
- p.add_argument("--style", default="lucid")
- p.add_argument("--theme", default=None, help="dark (default) or lucid.")
+ style_args(p)
p.add_argument("--overlay", type=Path,
- help="A hand-written overlay, re-applied on every build.")
+ help="A hand-written notebook overlay, re-applied on every build.")
p.add_argument("--exclude", action="append", default=[],
help="Directory name to skip. Repeatable.")
p.add_argument("--quiet", "-q", action="store_true")
args = p.parse_args(argv)
if args.root is not None:
- kind, source = args.lang, args.root
+ kind, source = args.reader, args.root
elif args.schema is not None:
kind, source = "db", args.schema
elif args.openapi is not None:
@@ -46,35 +50,24 @@ def main(argv=None) -> int:
kind, source = "usage", args.har
if not Path(source).exists():
- print(f"Error: {source} does not exist", file=sys.stderr)
- return 1
+ raise Abort(f"{source} does not exist")
overlay = None
if args.overlay:
if not args.overlay.exists():
# Absent is fine and is the documented default; named-but-missing is
# a typo, and quietly building without it would hide the typo.
- print(f"Error: overlay {args.overlay} does not exist", file=sys.stderr)
- return 1
+ raise Abort(f"overlay {args.overlay} does not exist")
from ..notebook import spec as spec_mod
overlay = spec_mod.load(args.overlay)
- from .build import run
+ from ..book.build import run
try:
book = run(kind, source, args.output, slug=args.slug, style=args.style,
theme=args.theme, exclude=tuple(args.exclude), overlay=overlay,
quiet=args.quiet)
except Exception as e: # noqa: BLE001 - the CLI reports, it does not traceback
- print(f"Error: {type(e).__name__}: {e}", file=sys.stderr)
- return 1
-
- # Exit 1 when the two ends do not reconcile. The book is still written —
- # the evidence is the point — but a build that lost input should fail a
- # pipeline rather than pass quietly.
- lost = [r for r in book.compare() if not r.get("ok")]
- return 1 if lost else 0
-
+ raise Abort(f"{type(e).__name__}: {e}") from None
-if __name__ == "__main__":
- sys.exit(main())
+ return 1 if any(not r["ok"] for r in book.compare()) else 0
diff --git a/soleprint/atlas2/docgen/cli/check.py b/soleprint/atlas2/docgen/cli/check.py
new file mode 100644
index 0000000..cdd981b
--- /dev/null
+++ b/soleprint/atlas2/docgen/cli/check.py
@@ -0,0 +1,50 @@
+"""
+ python3 -m docgen check docgen's own suite
+ python3 -m docgen check that book's own level
+ python3 -m docgen check --only generated|custom
+
+Two of docgen's three test levels, told apart by what they assert *about*. The
+third, the machine, is `make doctor`, and never fails.
+"""
+
+import argparse
+import json
+import subprocess
+import sys
+from pathlib import Path
+
+from .common import PROG, Abort
+
+HERE = Path(__file__).resolve().parent.parent
+
+
+def main(argv) -> int:
+ p = argparse.ArgumentParser(prog=f"{PROG} check")
+ p.add_argument("book", type=Path, nargs="?",
+ help="A book directory (holding book.json). Omit for docgen's own suite.")
+ p.add_argument("--only", choices=("generated", "custom"),
+ help="With a book: run one half rather than both.")
+ args = p.parse_args(argv)
+
+ if args.book is None:
+ if args.only:
+ raise Abort("--only applies to a book")
+ # A subprocess rather than an import: the suite is a script that runs at
+ # import time and exits, and it has to see a clean interpreter to test
+ # what a fresh one would do.
+ return subprocess.call([sys.executable, str(HERE / "selftest.py")])
+
+ from ..book.checks import Loaded, Report, custom, generated
+
+ try:
+ book = Loaded(args.book)
+ except (FileNotFoundError, json.JSONDecodeError) as e:
+ raise Abort(str(e)) from None
+
+ print(f"{book.dir} — {book.data.get('slug', '?')}")
+ r = Report()
+ if args.only != "custom":
+ generated(book, r)
+ if args.only != "generated":
+ custom(book, r)
+ return r.total()
diff --git a/soleprint/atlas2/docgen/cli/common.py b/soleprint/atlas2/docgen/cli/common.py
new file mode 100644
index 0000000..9f29687
--- /dev/null
+++ b/soleprint/atlas2/docgen/cli/common.py
@@ -0,0 +1,79 @@
+"""
+What every command does the same way — written once.
+
+Each old `cli_*` file re-implemented these by hand: read a JSON file and say so
+if it cannot, validate it and print the first five problems, load a style and
+report a bad name, write to a file or to stdout. Eight copies had already begun
+to differ in their wording. Now a command raises `Abort` and `cli.main` turns it
+into the one exit path: `Error: ` on stderr, exit 1.
+"""
+
+import json
+import sys
+from pathlib import Path
+
+# The package name is derived, not written: the folder can be copied anywhere
+# and renamed, and help text saying `docgen` for a folder called `docs2` lies.
+ROOT = __package__.rsplit(".", 1)[0]
+PROG = f"python3 -m {ROOT}"
+
+
+class Abort(Exception):
+ """An expected failure. Printed as one line, exit 1 — never a traceback."""
+
+
+def read_json(path: Path) -> dict:
+ try:
+ return json.loads(Path(path).read_text())
+ except (OSError, json.JSONDecodeError) as e:
+ raise Abort(f"could not read {path}: {e}") from None
+
+
+def read_ir(path: Path) -> dict:
+ """Read an IR and validate it at the boundary, or abort saying why."""
+ from ..ir import check
+
+ data = read_json(path)
+ problems = check(data)
+ if problems:
+ shown = "\n ".join(problems[:5])
+ more = f"\n … and {len(problems) - 5} more" if len(problems) > 5 else ""
+ raise Abort(f"{path} is not a valid IR ({len(problems)} problem(s)):\n {shown}{more}")
+ return data
+
+
+def load_style(name: str, theme: str | None):
+ from ..style import Style, StyleError
+
+ try:
+ return Style.load(name, theme=theme)
+ except StyleError as e:
+ raise Abort(str(e)) from None
+
+
+def style_args(p) -> None:
+ p.add_argument("--style", default="lucid", help="A shipped style, or a path to one.")
+ p.add_argument("--theme", default=None, help="dark (default) or lucid.")
+
+
+def emit(text, output: Path | None, report: str = "") -> None:
+ """Write to `output`, or to stdout when there is none."""
+ if output is None:
+ if isinstance(text, bytes):
+ sys.stdout.buffer.write(text)
+ else:
+ sys.stdout.write(text)
+ return
+ output = Path(output)
+ output.parent.mkdir(parents=True, exist_ok=True)
+ if isinstance(text, bytes):
+ output.write_bytes(text)
+ else:
+ output.write_text(text)
+ if report:
+ print(report)
+
+
+def dump_ir(ir, output: Path | None, report: str = "") -> None:
+ data = ir.to_dict() if hasattr(ir, "to_dict") else ir
+ emit(json.dumps(data, indent=2) + "\n", output, report)
diff --git a/soleprint/atlas2/docgen/cli/emit.py b/soleprint/atlas2/docgen/cli/emit.py
new file mode 100644
index 0000000..54d283f
--- /dev/null
+++ b/soleprint/atlas2/docgen/cli/emit.py
@@ -0,0 +1,252 @@
+"""
+ python3 -m docgen emit auto -o DIR whatever the structure asks for
+ python3 -m docgen emit dot [-o out.svg|.dot]
+ python3 -m docgen emit erd [-o out.svg]
+ python3 -m docgen emit index [-o out.md|.json]
+ python3 -m docgen emit minimap [-o out.svg] [--scale 0.55]
+ python3 -m docgen emit notebook [-o out.ipynb] [--overlay f.json]
+ python3 -m docgen emit site -o DIR
+ python3 -m docgen emit explore -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 --hops 2`, or `--subtree `. "
+ "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:])
diff --git a/soleprint/atlas2/docgen/cli/extract.py b/soleprint/atlas2/docgen/cli/extract.py
new file mode 100644
index 0000000..b1fd738
--- /dev/null
+++ b/soleprint/atlas2/docgen/cli/extract.py
@@ -0,0 +1,118 @@
+"""
+ python3 -m docgen extract python --root SRC [-o ir.json] [--exclude NAME ...]
+ python3 -m docgen extract code --root SRC [-o ir.json] [--ext .cs ...]
+ python3 -m docgen extract db --schema schema.json [-o ir.json]
+ python3 -m docgen extract openapi --spec spec.yaml [-o ir.json]
+ python3 -m docgen extract usage --har session.har [-o ir.json]
+
+Source -> IR, one reader per source type. Writes to stdout without `-o`, so it
+composes with `view` and `emit`.
+"""
+
+import argparse
+import json
+from collections import Counter
+from pathlib import Path
+
+from .common import PROG, Abort, dump_ir
+
+
+def _parser(reader: str, source_flag: str, help_: str) -> argparse.ArgumentParser:
+ p = argparse.ArgumentParser(prog=f"{PROG} extract {reader}")
+ p.add_argument(source_flag, "-s", required=True, type=Path, help=help_)
+ p.add_argument("--output", "-o", type=Path, help="Where to write. Default stdout.")
+ return p
+
+
+def _counts(g) -> str:
+ c = Counter(n.kind for n in g.nodes)
+ return " · ".join(f"{v} {k}" for k, v in sorted(c.items(), key=lambda kv: -kv[1]))
+
+
+def python(argv) -> int:
+ p = _parser("python", "--root", "Tree to read.")
+ p.add_argument("--exclude", action="append", default=[], help="Directory name to skip.")
+ args = p.parse_args(argv)
+ from ..extractors.python import extract
+
+ try:
+ g = extract(args.root, exclude=tuple(args.exclude))
+ except (NotADirectoryError, OSError) as e:
+ raise Abort(str(e)) from None
+ unparsed = sum(1 for n in g.nodes if n.attrs.get("error"))
+ dump_ir(g, args.output,
+ f"{len(g.nodes)} nodes, {len(g.edges)} edges -> {args.output}"
+ + (f" ({unparsed} file(s) unparsed)" if unparsed else ""))
+ return 0
+
+
+def code(argv) -> int:
+ p = _parser("code", "--root", "Tree to read.")
+ p.add_argument("--ext", action="append", default=[],
+ help="Limit to these extensions. Default: every registered one.")
+ p.add_argument("--exclude", action="append", default=[], help="Directory name to skip.")
+ args = p.parse_args(argv)
+ from ..extractors.code import MissingParser, extract
+
+ try:
+ g = extract(args.root, suffixes=args.ext or None, exclude=tuple(args.exclude))
+ except (MissingParser, NotADirectoryError, OSError) as e:
+ raise Abort(str(e)) from None
+ dump_ir(g, args.output, f"{len(g.nodes)} nodes -> {args.output} {_counts(g)}")
+ return 0
+
+
+def db(argv) -> int:
+ args = _parser("db", "--schema",
+ "A graphgen-compatible schema.json, as modelgen emits.").parse_args(argv)
+ from ..extractors.db import extract
+
+ try:
+ g = extract(args.schema)
+ except (OSError, json.JSONDecodeError, KeyError) as e:
+ raise Abort(f"could not read {args.schema}: {e}") from None
+ dump_ir(g, args.output, f"{len(g.nodes)} nodes, {len(g.edges)} edges -> {args.output}")
+ return 0
+
+
+def openapi(argv) -> int:
+ args = _parser("openapi", "--spec", "An OpenAPI document.").parse_args(argv)
+ from ..extractors.openapi import extract
+
+ try:
+ g = extract(args.spec)
+ except (OSError, ImportError, ValueError) as e:
+ raise Abort(str(e)) from None
+ eps = sum(1 for n in g.nodes if n.kind == "endpoint")
+ dump_ir(g, args.output,
+ f"{len(g.nodes)} nodes ({eps} endpoints), {len(g.edges)} edges -> {args.output}")
+ return 0
+
+
+def usage(argv) -> int:
+ args = _parser("usage", "--har",
+ "A HAR recording, as devtools/mitmproxy/Charles export.").parse_args(argv)
+ from ..extractors.usage import extract
+
+ try:
+ g = extract(args.har)
+ except (OSError, json.JSONDecodeError, KeyError) as e:
+ raise Abort(f"could not read {args.har}: {e}") from None
+ eps = sum(1 for n in g.nodes if n.kind == "endpoint")
+ ops = sum(1 for n in g.nodes if n.kind == "operation")
+ dump_ir(g, args.output,
+ f"{len(g.nodes)} nodes ({eps} endpoints, {ops} graphql), "
+ f"{len(g.edges)} sequence edges -> {args.output}")
+ return 0
+
+
+READERS = {"python": python, "code": code, "db": db, "openapi": openapi, "usage": usage}
+
+
+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 READERS:
+ raise Abort(f"no reader {argv[0]!r} — have: {', '.join(READERS)}")
+ return READERS[argv[0]](argv[1:])
diff --git a/soleprint/atlas2/docgen/cli/run.py b/soleprint/atlas2/docgen/cli/run.py
new file mode 100644
index 0000000..5c9775b
--- /dev/null
+++ b/soleprint/atlas2/docgen/cli/run.py
@@ -0,0 +1,79 @@
+"""
+ python3 -m docgen run [docgen.toml] [--only NAME ...] [--check] [--list]
+
+Every book a run file lists, rebuilt in order. See `book/config.py` for the file.
+
+ --only NAME just these books (repeatable), still in run-file order
+ --check run each book's own level after building it
+ --list print what would run, fully resolved, and build nothing
+
+One failing book never stops the rest; the run exits 1 if any book failed to
+build, lost input between its two ends, or failed its checks. Without a path it
+reads `docgen.toml` in the current directory.
+"""
+
+import argparse
+from pathlib import Path
+
+from .common import PROG, Abort
+
+
+def main(argv) -> int:
+ p = argparse.ArgumentParser(prog=f"{PROG} run")
+ p.add_argument("config", type=Path, nargs="?", default=Path("docgen.toml"))
+ p.add_argument("--only", action="append", default=[], metavar="NAME")
+ p.add_argument("--check", action="store_true",
+ help="Run each book's own level after building it.")
+ p.add_argument("--list", action="store_true",
+ help="Print what would run, resolved, and build nothing.")
+ p.add_argument("--verbose", "-v", action="store_true",
+ help="Show each book's own output, not just the summary.")
+ args = p.parse_args(argv)
+
+ from ..book.config import ConfigError, load, run
+
+ if not args.config.exists():
+ raise Abort(f"no run file at {args.config} — see docgen.example.toml")
+ try:
+ runfile = load(args.config)
+ selected = runfile.select(args.only)
+ except ConfigError as e:
+ raise Abort(str(e)) from None
+
+ if args.list:
+ # The resolved form, because the whole question a run file raises is
+ # "relative to what" — and the answer should be visible, not inferred.
+ print(f"{runfile.path} — {len(selected)} of {len(runfile.books)} book(s)")
+ if runfile.reference:
+ print(f" reference {runfile.reference}")
+ for b in selected:
+ print(f" {b.line()}")
+ return 0
+
+ print(f"{runfile.path} — {len(selected)} book(s)")
+ results = run(runfile, [b.name for b in selected], check=args.check,
+ quiet=not args.verbose)
+
+ width = max(len(r["name"]) for r in results)
+ for r in results:
+ if r["error"]:
+ # First line only: a missing-reference error is a paragraph of advice,
+ # and in a summary table it buries the next book's row.
+ status, detail = "FAIL", r["error"].splitlines()[0]
+ elif r["lost"]:
+ status, detail = "LOST", "; ".join(r["lost"])
+ elif r["checks_failed"]:
+ status, detail = "FAIL", f"{len(r['checks_failed'])} check(s): " \
+ + ", ".join(r["checks_failed"][:3])
+ else:
+ status, detail = "ok ", r["out"]
+ print(f" {status} {r['name']:<{width}} {detail}")
+
+ failed = [r for r in results if not r["ok"]]
+ print()
+ if failed:
+ print(f"{len(failed)} of {len(results)} book(s) did not hold"
+ + ("" if args.verbose else " — --verbose for each book's own output"))
+ return 1
+ print(f"{len(results)} book(s) built" + (" and checked" if args.check else ""))
+ return 0
diff --git a/soleprint/atlas2/docgen/cli/validate.py b/soleprint/atlas2/docgen/cli/validate.py
new file mode 100644
index 0000000..29c1f43
--- /dev/null
+++ b/soleprint/atlas2/docgen/cli/validate.py
@@ -0,0 +1,32 @@
+"""
+ python3 -m docgen validate
+
+Check a document at the boundary. Also checks that `ir/model.py` and
+`ir/schema.json` still agree, since a document can only be as right as the
+contract it is checked against.
+"""
+
+import argparse
+from pathlib import Path
+
+from .common import PROG, Abort, read_json
+
+
+def main(argv) -> int:
+ p = argparse.ArgumentParser(prog=f"{PROG} validate")
+ p.add_argument("ir", type=Path)
+ args = p.parse_args(argv)
+ from ..ir.validate import check, check_model_matches_schema
+
+ drift = check_model_matches_schema()
+ if drift:
+ raise Abort("model.py and schema.json disagree:\n " + "\n ".join(drift))
+
+ data = read_json(args.ir)
+ problems = check(data)
+ if problems:
+ raise Abort(f"{args.ir}: {len(problems)} problem(s)\n " + "\n ".join(problems))
+
+ print(f"{args.ir}: ok — {len(data['nodes'])} nodes, {len(data['edges'])} edges, "
+ f"schema v{data['meta'].get('schema_version')}")
+ return 0
diff --git a/soleprint/atlas2/docgen/ops/__main__.py b/soleprint/atlas2/docgen/cli/view.py
similarity index 64%
rename from soleprint/atlas2/docgen/ops/__main__.py
rename to soleprint/atlas2/docgen/cli/view.py
index 470f02a..7000958 100644
--- a/soleprint/atlas2/docgen/ops/__main__.py
+++ b/soleprint/atlas2/docgen/cli/view.py
@@ -1,18 +1,19 @@
-""" python3 -m docgen.ops [views...] [-o out.json]
+"""
+ python3 -m docgen view [views...] [-o out.json]
-Views compose, left to right, in the order given on the command line."""
+IR -> a smaller IR. Views compose, applied in a fixed order: overview, the drops,
+only, subtree, around, depth. Each produces a document that still validates.
+"""
import argparse
import json
-import sys
from pathlib import Path
-from ..ir import check
-from . import filter as F
+from .common import PROG, Abort, emit, read_json
-def main(argv=None):
- p = argparse.ArgumentParser(prog="python3 -m docgen.ops")
+def main(argv) -> int:
+ p = argparse.ArgumentParser(prog=f"{PROG} view")
p.add_argument("ir", type=Path)
p.add_argument("--output", "-o", type=Path)
p.add_argument("--overview", action="store_true",
@@ -32,12 +33,12 @@ def main(argv=None):
help="Report what this will look like, and write nothing.")
args = p.parse_args(argv)
- try:
- ir = json.loads(args.ir.read_text())
- except (OSError, json.JSONDecodeError) as e:
- print(f"Error: could not read {args.ir}: {e}", file=sys.stderr)
- return 1
+ from ..ir import check
+ from ..ops import filter as F
+ # Read without validating first: a view is often how an oversized document
+ # is made readable, and the result is validated before it is written.
+ ir = read_json(args.ir)
before = (len(ir["nodes"]), len(ir["edges"]))
if args.overview:
@@ -60,42 +61,29 @@ def main(argv=None):
ir = F.collapse_to_depth(ir, args.depth)
if args.shape:
- sh = F.shape(ir)
- for k, v in sh.items():
+ for k, v in F.shape(ir).items():
print(f" {k:<14} {v}")
return 0
if args.split:
if not args.output:
- print("Error: --split needs -o DIRECTORY", file=sys.stderr)
- return 1
+ raise Abort("--split needs -o DIRECTORY")
args.output.mkdir(parents=True, exist_ok=True)
for name, part in F.split(ir).items():
- (args.output / f"{name}.json").write_text(json.dumps(part, indent=2) + "\n")
+ target = args.output / f"{name}.json"
+ target.write_text(json.dumps(part, indent=2) + "\n")
sh = F.shape(part)
- print(f" {name:<16} {sh['nodes']:>4} nodes {sh['edges']:>4} edges "
- f"-> {args.output / (name + '.json')}")
+ print(f" {name:<16} {sh['nodes']:>4} nodes {sh['edges']:>4} edges -> {target}")
return 0
problems = check(ir)
if problems:
# A view that produces an invalid document is a bug in the view, and it
# must not be written out for an emitter to trip over later.
- print(f"Error: the view produced an invalid IR ({len(problems)}):", file=sys.stderr)
- for pr in problems[:5]:
- print(f" {pr}", file=sys.stderr)
- return 1
+ raise Abort(f"the view produced an invalid IR ({len(problems)}):\n "
+ + "\n ".join(problems[:5]))
- text = json.dumps(ir, indent=2) + "\n"
- if args.output:
- args.output.parent.mkdir(parents=True, exist_ok=True)
- args.output.write_text(text)
- print(f" {before[0]} nodes, {before[1]} edges -> "
- f"{len(ir['nodes'])} nodes, {len(ir['edges'])} edges -> {args.output}")
- else:
- sys.stdout.write(text)
+ emit(json.dumps(ir, indent=2) + "\n", args.output,
+ f" {before[0]} nodes, {before[1]} edges -> "
+ f"{len(ir['nodes'])} nodes, {len(ir['edges'])} edges -> {args.output}")
return 0
-
-
-if __name__ == "__main__":
- sys.exit(main())
diff --git a/soleprint/atlas2/docgen/docgen.example.toml b/soleprint/atlas2/docgen/docgen.example.toml
new file mode 100644
index 0000000..f2ca2c4
--- /dev/null
+++ b/soleprint/atlas2/docgen/docgen.example.toml
@@ -0,0 +1,45 @@
+# A run file: every book a project builds, rebuilt with one command.
+#
+# make run CONFIG=docgen.example.toml
+# python3 -m docgen run docgen.example.toml --only schema --check
+# python3 -m docgen run docgen.example.toml --list # resolved, nothing built
+#
+# Copy it next to the project it describes and edit the books. Paths are relative
+# to THIS FILE, not to where the command runs — so the same file builds the same
+# books from any directory. Unknown keys are refused rather than ignored.
+
+# Optional. Where the repo holding station/tools/modelgen is, for reading
+# OpenAPI. $DOCGEN_REFERENCE wins over this when it is set.
+# reference = "../../"
+
+[defaults]
+out = "out/run" # each book lands in / unless it sets `out`
+style = "lucid"
+theme = "dark"
+# A book's own value REPLACES a default, for every key — exclude included.
+exclude = ["__pycache__", "node_modules"]
+
+# A tree of Python — here, docgen's own extractors. A book may not be written
+# inside the tree it reads (the next run would read the last run's output), so a
+# `root` of "." would need an `out` outside this folder.
+[[book]]
+name = "extractors"
+root = "extractors"
+
+# A database schema, as modelgen writes it.
+[[book]]
+name = "schema"
+schema = "fixtures/shop.json"
+
+# An OpenAPI document. Needs pyyaml and the reference repo; without them this
+# one book fails and says why, and the others still build.
+[[book]]
+name = "api"
+openapi = "fixtures/orders.yaml"
+theme = "lucid"
+
+# Another tree read with tree-sitter instead (C#, TypeScript):
+# [[book]]
+# name = "frontend"
+# root = "../frontend/src"
+# reader = "code"
diff --git a/soleprint/atlas2/docgen/docs/img/architecture.svg b/soleprint/atlas2/docgen/docs/img/architecture.svg
index efe5476..9df61e1 100644
--- a/soleprint/atlas2/docgen/docs/img/architecture.svg
+++ b/soleprint/atlas2/docgen/docs/img/architecture.svg
@@ -4,991 +4,849 @@
-
# 1. read something
-python3 -m docgen.extractors.python --root ../station/tools/histgen -o ir.json
+python3 -m docgen extract python --root ../station/tools/histgen -o ir.json
# 2. narrow it to a useful view
-python3 -m docgen.ops ir.json --overview -o view.json
+python3 -m docgen view ir.json --overview -o view.json
# 3. draw whatever its structure asks for
-python3 -m docgen.emitters auto view.json -o out/
+python3 -m docgen emit auto view.json -o out/
Or through the Makefile, which is a thin wrapper over exactly those:
@@ -281,7 +287,7 @@ make self # docgen's book of soleprint
or the picture is smaller than the source and says nothing about it.
- python3 -m docgen.book exits 1 when a reconciliation fails. The
+ python3 -m docgen book exits 1 when a reconciliation fails. The
book is still written — the evidence is the point — but a build that lost input
should fail a pipeline rather than pass quietly.
@@ -383,7 +389,7 @@ make check BOOK=out/book/station
and it reads its field lists out of schema.json so the schema and
the dataclasses cannot drift apart.
-
python3 -m docgen.ir ir.json
+
python3 -m docgen validate ir.json
It catches what a schema cannot: an edge naming a node that does not exist, a
containment cycle, a duplicate id, and a visual field smuggled into
@@ -416,7 +422,7 @@ make check BOOK=out/book/station
flat
the index
most nodes have no relationships: that is a list
-
$ python3 -m docgen.emitters auto view.json -o out/
+
$ python3 -m docgen emit auto view.json -o out/
sheet -> index
109 nodes sit at one level; any layered engine draws that as a
strip. Split it, scope it, or read it as an index
@@ -537,10 +543,10 @@ make check BOOK=out/book/station
All of them are IR→IR, all composable, and each produces a document that
@@ -738,8 +744,8 @@ python3 -m docgen.ops ir.json --shape # what will this
absent — it is an addition, never a dependency.
-
cp -r docgen /somewhere/else
cd /somewhere/else/docgen
make doctor # what this machine has
+make sync # optional: every group, from uv.lock
make check # the suite, from the copy
make book SRC=/path/to/any/repo BOOK=out/book/theirs
@@ -865,19 +872,116 @@ make doctor
context
checks
skipped
-
in the repo
250
tree-sitter (259 with it)
-
copied out
242
tree-sitter, OpenAPI, the in-place case
-
copied out, DOCGEN_REFERENCE set
249
-
tree-sitter, the in-place case
+
in the repo, synced
287
—
+
copied out, bare python3
270
tree-sitter, OpenAPI, the in-place case
+
copied out, synced
279
OpenAPI, the in-place case
+
copied out, synced, DOCGEN_REFERENCE set
286
+
the in-place case — correctly, there is no repo above
+
+
Run files
+
+
+ A book gets 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. A run file writes them down
+ once, so the rebuild is exact.
+
make run CONFIG=docgen.toml
+make run CONFIG=docgen.toml ONLY=station,frontend CHECK=1
+python3 -m docgen run docgen.toml --list # resolved paths, nothing built
+
+
docgen.toml — 3 book(s)
+ ok station /home/me/proj/out/book/station
+ ok frontend /home/me/proj/out/book/frontend
+ FAIL orders-api ImportError: modelgen is not reachable — …
+
+1 of 3 book(s) did not hold — --verbose for each book's own output
+
+
+ Each book names exactly one source — root,
+ schema, openapi or har — the same choice
+ the book command makes you take. One failing book never stops the
+ rest, and the run exits 1 if any book failed to build, lost input between its
+ two ends, or failed its checks. docgen.example.toml ships with
+ docgen and runs against its own fixtures.
+
+
+
The rules, each one a thing that goes wrong otherwise
+
+
+
Paths are relative to the run file, not to wherever the command ran.
+ Otherwise the same file builds different books from different
+ directories.
+
A book's value replaces the default, for every key — including
+ exclude, where merging looks tempting. One rule nobody has to
+ remember beats a clever one somebody has to look up. A default
+ exclude simply does not apply to a schema or a spec.
+
Unknown keys are refused, not ignored, all of them at once.
+ exlude = [...] silently doing nothing is how a rebuild quietly
+ starts reading node_modules.
+
A book may not be written inside the tree it reads — the next run
+ would read the last run's output — and two books may not share an output
+ directory, where the second would clear the first every time.
+
The environment beats the file. A reference in the run
+ file is used only when $DOCGEN_REFERENCE is not set, which is
+ rig's precedence for every setting it has.
+
+
+
+ A rebuild replaces the previous build, and nothing else. Before writing,
+ a book removes exactly what a build writes — book.json,
+ notebook.ipynb, steps/, site/,
+ explore/ — and only from a directory that already holds a
+ book.json. Without it a graph that is now graph.md kept
+ its old graph.svg, unlisted in the ledger and still on the page.
+ A hand-written checks.py or overlay.json is never
+ touched, and neither is a directory docgen did not write.
+
+
+
+ TOML because a run file is written by people: tomllib is the
+ stdlib, it takes comments — 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 the style files stay JSON,
+ because those are data that tools write.
+
Variables: SRC, OUT, SCHEMA,
OPENAPI, HAR, BOOK, SLUG,
- READER, OVERLAY, STYLE,
- THEME, SCALE, DEPTH, PY.
+ READER, OVERLAY, CONFIG,
+ ONLY, CHECK, STYLE, THEME,
+ SCALE, PY. PY is the synced
+ .venv when there is one and python3 when there is not.
The Makefile derives its own package name from where it sits, so the folder can
be copied anywhere and renamed and still work.
@@ -909,49 +1015,100 @@ make doctor
property the spine exists to preserve, not to replace.
-
Modules
-
python3 -m docgen.book --root SRC -o out/book/slug # the whole operation
-python3 -m docgen.book.checks out/book/slug # that book's level
+
The command line
+
+
+ One entry point, python3 -m docgen <command>, run from the
+ directory above the folder (or with it on PYTHONPATH, which
+ is what the Makefile does). Each command is plain argparse and takes
+ --help.
+
+ All of it in cli/, and nowhere else. extractors/,
+ ops/, emitters/ and book/ hold library
+ code only — no argparse, no __main__.py, no
+ cli_dot.py sitting next to dot.py. Two kinds of file
+ in one folder make every folder answer two questions, and the command-line half
+ is the half that grows copies: eight cli_* files each
+ re-implemented "read the IR, validate it, load the style" by hand, and had begun
+ to word their errors differently.
+
+
+
cli/
+├── __init__.py the command table and the one exit path
+├── common.py read and validate an IR, load a style, write or print
+├── book.py run.py check.py
+└── extract.py validate.py view.py emit.py
+
+
+ A command reports an expected failure by raising, and cli.main
+ turns it into the only exit path: one Error: line on stderr, exit 1,
+ never a traceback. Doing that split also surfaced a third copy of "which drawing
+ does this graph want" — in the auto command, the site
+ command and the book — which is now emitters.auto.draw(), returning
+ content and leaving the file name to the caller.
+
Dependencies
- Everything below is optional. The stdlib covers the whole structural path — see standalone
- for the one seam out of the folder.
+ The core is standard library only, Python 3.11 or later —
+ pyproject.toml declares no required dependency at all. Everything
+ else is an optional dependency group, pinned in a committed
+ uv.lock, and reported by make doctor; when something is
+ missing you lose exactly one capability and get told what to install. See
+ standalone for the one seam out of the folder.
+
make sync # uv sync --all-groups → .venv/
+uv sync --group code # or just the one you need
+make lock # after editing pyproject.toml
+
- The core is standard library only. Everything else is optional
- and reported by make doctor; when something is missing you lose
- exactly one capability and get told what to install.
+ [tool.uv] package = false, the same choice
+ station/tools/dataconvert makes: this is a folder of modules run in
+ place, not something to install. The lock pins what the optional groups resolve
+ to; the folder still runs on a bare python3 without it.
- All 259 with both optional dependencies installed, 244 with neither — the suite
- skips rather than fails when tree-sitter, lxml or the OpenAPI reader is absent.
- See standalone for the counts outside the repo.
+ All 287 after make sync, 272 with nothing optional installed — the
+ suite skips rather than fails when tree-sitter, lxml or the OpenAPI reader is
+ absent. See standalone for the counts outside the repo.
+ Among them, two that keep this page's structure true: nothing outside
+ cli/ imports argparse, and every import the suite allows is a group
+ in pyproject.toml and pinned in uv.lock.
The book level, where custom checks live
diff --git a/soleprint/atlas2/docgen/emitters/__main__.py b/soleprint/atlas2/docgen/emitters/__main__.py
deleted file mode 100644
index efc9246..0000000
--- a/soleprint/atlas2/docgen/emitters/__main__.py
+++ /dev/null
@@ -1,36 +0,0 @@
-""" python3 -m docgen.emitters [options]"""
-
-import sys
-
-
-def main(argv=None):
- argv = sys.argv[1:] if argv is None else argv
- if not argv:
- print("usage: python3 -m docgen.emitters [-o OUT] [--style NAME] [--theme NAME]",
- file=sys.stderr)
- return 2
- name, rest = argv[0], argv[1:]
- if name == "dot":
- from .cli_dot import main as run
- elif name == "index":
- from .cli_index import main as run
- elif name == "erd":
- from .cli_erd import main as run
- elif name == "auto":
- from .auto import main as run
- elif name == "notebook":
- from .cli_notebook import main as run
- elif name == "site":
- from .cli_site import main as run
- elif name == "minimap":
- from .cli_minimap import main as run
- elif name == "explore":
- from .cli_explore import main as run
- else:
- print(f"Error: no emitter {name!r} — have: auto, dot, index, erd, notebook, site, minimap, explore", file=sys.stderr)
- return 1
- return run(rest)
-
-
-if __name__ == "__main__":
- sys.exit(main())
diff --git a/soleprint/atlas2/docgen/emitters/auto.py b/soleprint/atlas2/docgen/emitters/auto.py
index 832d132..b7e8aa2 100644
--- a/soleprint/atlas2/docgen/emitters/auto.py
+++ b/soleprint/atlas2/docgen/emitters/auto.py
@@ -1,79 +1,63 @@
"""
Draw it the way its structure asks to be drawn.
- python3 -m docgen.emitters auto ir.json -o out/
-
`ops.classify` reads the structure and names an emitter; this runs it. The whole
point is that nobody should have to know that a schema wants cards and a module
graph wants ranks — or discover it from a 235:1 image.
-When the answer is "this is not a diagram", it says so and writes the index,
+When the answer is "this is not a diagram", it says so and produces the index,
because that *is* the right artifact for a flat list of peers.
-"""
-import argparse
-import json
-import sys
-from pathlib import Path
+## One function, three callers
-from ..ir import check
-from ..ops import classify
-from ..style import Style, StyleError
+This branch — erd, or index, or dot through Graphviz — used to be written out
+three times: in the `auto` command, in the `site` command choosing its picture,
+and in the book choosing its graph step. Three copies of "which drawing does this
+graph want" is three chances for them to disagree about the answer, so it lives
+here and returns content rather than writing it. Where the file goes, and what
+to call it, is the caller's business.
+"""
+from ..ops import classify
-def main(argv=None):
- p = argparse.ArgumentParser(prog="python3 -m docgen.emitters auto")
- p.add_argument("ir", type=Path)
- p.add_argument("--output", "-o", type=Path, help="Directory to write into.")
- p.add_argument("--style", default="lucid")
- p.add_argument("--theme", default=None)
- p.add_argument("--force", help="Use this emitter regardless of what fits.")
- args = p.parse_args(argv)
+# What each emitter produces, so a caller can name the file without knowing
+# which emitter ran.
+SUFFIX = {"erd": ".svg", "dot": ".svg", "index": ".md"}
- try:
- data = json.loads(args.ir.read_text())
- except (OSError, json.JSONDecodeError) as e:
- print(f"Error: could not read {args.ir}: {e}", file=sys.stderr)
- return 1
- problems = check(data)
- if problems:
- print(f"Error: {args.ir} is not a valid IR ({len(problems)}):", file=sys.stderr)
- for pr in problems[:5]:
- print(f" {pr}", file=sys.stderr)
- return 1
+def draw(ir: dict, style, *, force: str | None = None) -> dict:
+ """The drawing this graph asks for, as content.
- verdict = classify(data)
- chosen = args.force or verdict["emitter"]
- print(f" {verdict['kind']:<8} -> {chosen}")
- print(f" {verdict['why']}")
+ Returns::
- out_dir = args.output or Path(".")
- out_dir.mkdir(parents=True, exist_ok=True)
- stem = args.ir.stem
+ {"verdict": ,
+ "emitter": "erd" | "dot" | "index",
+ "suffix": ".svg" | ".md",
+ "content": str | bytes | None, # None when it could not be drawn
+ "why": why this emitter, or why nothing was drawn}
- try:
- style = Style.load(args.style, theme=args.theme)
- except StyleError as e:
- print(f"Error: {e}", file=sys.stderr)
- return 1
+ Never raises for a missing Graphviz. A layered graph on a machine without
+ `dot` is a normal state — the book records the step as skipped and the site
+ goes text-only — so it is reported in `why` with `content` None.
+ """
+ verdict = classify(ir)
+ chosen = force or verdict["emitter"]
+ out = {"verdict": verdict, "emitter": chosen, "suffix": SUFFIX.get(chosen, ".svg"),
+ "content": None, "why": verdict["why"]}
if chosen == "erd":
- from .erd import emit as erd_emit
-
- path = out_dir / f"{stem}.svg"
- path.write_text(erd_emit(data, style))
+ from .erd import emit
+ out["content"] = emit(ir, style)
elif chosen == "index":
from .index import to_markdown
-
- path = out_dir / f"{stem}.md"
- path.write_text(to_markdown(data))
+ out["content"] = to_markdown(ir)
+ elif chosen == "dot":
+ from .dot import emit, have_graphviz, render
+ if not have_graphviz():
+ out["why"] = "Graphviz is not installed — nothing drawn (apt install graphviz)"
+ else:
+ opts = verdict.get("options") or {}
+ out["content"] = render(emit(ir, style, rankdir=opts.get("rankdir")))
else:
- from .dot import emit as dot_emit, render
-
- path = out_dir / f"{stem}.svg"
- opts = verdict.get("options") or {}
- path.write_bytes(render(dot_emit(data, style, rankdir=opts.get("rankdir"))))
-
- print(f" {path}")
- return 0
+ raise ValueError(f"no emitter {chosen!r} — have: erd, dot, index")
+ return out
diff --git a/soleprint/atlas2/docgen/emitters/cli_dot.py b/soleprint/atlas2/docgen/emitters/cli_dot.py
deleted file mode 100644
index d346f80..0000000
--- a/soleprint/atlas2/docgen/emitters/cli_dot.py
+++ /dev/null
@@ -1,86 +0,0 @@
-""" python3 -m docgen.emitters dot [-o out.svg] [--style lucid] [--theme dark]"""
-
-import argparse
-import json
-import sys
-from pathlib import Path
-
-from ..ir import check
-from ..ops import shape
-from ..style import Style, StyleError
-from .dot import RenderError, emit, render
-
-
-def main(argv=None):
- p = argparse.ArgumentParser(prog="python3 -m docgen.emitters dot")
- p.add_argument("ir", type=Path)
- p.add_argument("--output", "-o", type=Path, help="Write here. .dot or .svg by suffix.")
- p.add_argument("--style", default="lucid")
- p.add_argument("--theme", default=None)
- 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)
-
- try:
- data = json.loads(args.ir.read_text())
- except (OSError, json.JSONDecodeError) as e:
- print(f"Error: could not read {args.ir}: {e}", file=sys.stderr)
- return 1
-
- problems = check(data)
- if problems:
- print(f"Error: {args.ir} is not a valid IR ({len(problems)} problem(s)):", file=sys.stderr)
- for pr in problems[:5]:
- print(f" {pr}", file=sys.stderr)
- return 1
-
- try:
- style = Style.load(args.style, theme=args.theme)
- except StyleError as e:
- print(f"Error: {e}", file=sys.stderr)
- return 1
-
- # 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.",
- file=sys.stderr,
- )
- print(
- " Around 20 nodes is where it stops being a diagram. Try "
- "`ops --split`,\n `--around --hops 2`, or `--subtree `. "
- "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:
- sys.stdout.write(dot_text)
- return 0
-
- args.output.parent.mkdir(parents=True, exist_ok=True)
- if args.output.suffix == ".dot":
- args.output.write_text(dot_text)
- else:
- try:
- args.output.write_bytes(render(dot_text, fmt=args.output.suffix.lstrip(".") or "svg"))
- except RenderError as e:
- print(f"Error: {e}", file=sys.stderr)
- return 1
- print(f" {args.style}/{style.theme:6} {args.output}")
- return 0
diff --git a/soleprint/atlas2/docgen/emitters/cli_erd.py b/soleprint/atlas2/docgen/emitters/cli_erd.py
deleted file mode 100644
index aedd46e..0000000
--- a/soleprint/atlas2/docgen/emitters/cli_erd.py
+++ /dev/null
@@ -1,50 +0,0 @@
-""" python3 -m docgen.emitters erd [-o out.svg] [--theme dark]"""
-
-import argparse
-import json
-import sys
-from pathlib import Path
-
-from ..ir import check
-from ..style import Style, StyleError
-from .erd import emit
-
-
-def main(argv=None):
- p = argparse.ArgumentParser(prog="python3 -m docgen.emitters erd")
- p.add_argument("ir", type=Path)
- p.add_argument("--output", "-o", type=Path)
- p.add_argument("--style", default="lucid")
- p.add_argument("--theme", default=None)
- args = p.parse_args(argv)
-
- try:
- data = json.loads(args.ir.read_text())
- except (OSError, json.JSONDecodeError) as e:
- print(f"Error: could not read {args.ir}: {e}", file=sys.stderr)
- return 1
-
- problems = check(data)
- if problems:
- print(f"Error: {args.ir} is not a valid IR ({len(problems)} problem(s)):", file=sys.stderr)
- for pr in problems[:5]:
- print(f" {pr}", file=sys.stderr)
- return 1
-
- try:
- style = Style.load(args.style, theme=args.theme)
- svg = emit(data, style)
- except (StyleError, ValueError) as e:
- print(f"Error: {e}", file=sys.stderr)
- return 1
-
- if args.output:
- args.output.parent.mkdir(parents=True, exist_ok=True)
- args.output.write_text(svg)
- import re
- m = re.search(r'width="(\d+)pt" height="(\d+)pt"', svg)
- size = f"{m.group(1)}x{m.group(2)} {int(m.group(1))/int(m.group(2)):.1f}:1" if m else ""
- print(f" erd/{style.theme:6} {args.output} {size}")
- else:
- sys.stdout.write(svg)
- return 0
diff --git a/soleprint/atlas2/docgen/emitters/cli_explore.py b/soleprint/atlas2/docgen/emitters/cli_explore.py
deleted file mode 100644
index 0b2d6f4..0000000
--- a/soleprint/atlas2/docgen/emitters/cli_explore.py
+++ /dev/null
@@ -1,47 +0,0 @@
-""" python3 -m docgen.emitters explore -o DIR [--scale 0.55] [--hops 1]"""
-
-import argparse
-import json
-import sys
-from pathlib import Path
-
-from ..ir import check
-from ..style import Style, StyleError
-from .explore import write
-
-
-def main(argv=None):
- p = argparse.ArgumentParser(prog="python3 -m docgen.emitters explore")
- p.add_argument("ir", type=Path)
- p.add_argument("--output", "-o", type=Path, required=True, help="Directory.")
- p.add_argument("--style", default="lucid")
- p.add_argument("--theme", default=None)
- 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)
-
- try:
- data = json.loads(args.ir.read_text())
- except (OSError, json.JSONDecodeError) as e:
- print(f"Error: could not read {args.ir}: {e}", file=sys.stderr)
- return 1
- problems = check(data)
- if problems:
- print(f"Error: {args.ir} is not a valid IR ({len(problems)}):", file=sys.stderr)
- for pr in problems[:5]:
- print(f" {pr}", file=sys.stderr)
- return 1
-
- try:
- style = Style.load(args.style, theme=args.theme)
- path = write(data, style, args.output, scale=args.scale, width=args.width,
- hops=args.hops, title=args.title)
- except (StyleError, ValueError) as e:
- print(f"Error: {e}", file=sys.stderr)
- return 1
-
- graphs = len(list((args.output / "graphs").glob("*.svg"))) if (args.output / "graphs").exists() else 0
- print(f" explore {path} {graphs} neighbourhood diagram(s)")
- return 0
diff --git a/soleprint/atlas2/docgen/emitters/cli_index.py b/soleprint/atlas2/docgen/emitters/cli_index.py
deleted file mode 100644
index 0ab5944..0000000
--- a/soleprint/atlas2/docgen/emitters/cli_index.py
+++ /dev/null
@@ -1,43 +0,0 @@
-""" python3 -m docgen.emitters index [-o out.md|out.json]"""
-
-import argparse
-import json
-import sys
-from pathlib import Path
-
-from ..ir import check
-from .index import to_markdown, to_sidebar
-
-
-def main(argv=None):
- p = argparse.ArgumentParser(prog="python3 -m docgen.emitters index")
- p.add_argument("ir", type=Path)
- p.add_argument("--output", "-o", type=Path, help=".md for the document, .json for a sidebar.")
- p.add_argument("--title", default="")
- args = p.parse_args(argv)
-
- try:
- data = json.loads(args.ir.read_text())
- except (OSError, json.JSONDecodeError) as e:
- print(f"Error: could not read {args.ir}: {e}", file=sys.stderr)
- return 1
-
- problems = check(data)
- if problems:
- print(f"Error: {args.ir} is not a valid IR ({len(problems)} problem(s)):", file=sys.stderr)
- for pr in problems[:5]:
- print(f" {pr}", file=sys.stderr)
- return 1
-
- 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)
-
- if args.output:
- args.output.parent.mkdir(parents=True, exist_ok=True)
- args.output.write_text(text)
- print(f" index {args.output}")
- else:
- sys.stdout.write(text)
- return 0
diff --git a/soleprint/atlas2/docgen/emitters/cli_minimap.py b/soleprint/atlas2/docgen/emitters/cli_minimap.py
deleted file mode 100644
index 52cbfd5..0000000
--- a/soleprint/atlas2/docgen/emitters/cli_minimap.py
+++ /dev/null
@@ -1,50 +0,0 @@
-""" python3 -m docgen.emitters minimap [-o out.svg] [--scale 0.55]"""
-
-import argparse
-import json
-import re
-import sys
-from pathlib import Path
-
-from ..ir import check
-from ..style import Style, StyleError
-from .minimap import emit
-
-
-def main(argv=None):
- p = argparse.ArgumentParser(prog="python3 -m docgen.emitters minimap")
- p.add_argument("ir", type=Path)
- p.add_argument("--output", "-o", type=Path)
- p.add_argument("--style", default="lucid")
- p.add_argument("--theme", default=None)
- 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)
-
- try:
- data = json.loads(args.ir.read_text())
- except (OSError, json.JSONDecodeError) as e:
- print(f"Error: could not read {args.ir}: {e}", file=sys.stderr)
- return 1
- problems = check(data)
- if problems:
- print(f"Error: {args.ir} is not a valid IR ({len(problems)}):", file=sys.stderr)
- for pr in problems[:5]:
- print(f" {pr}", file=sys.stderr)
- return 1
-
- try:
- style = Style.load(args.style, theme=args.theme)
- svg = emit(data, style, scale=args.scale, target_width=args.width)
- except (StyleError, ValueError) as e:
- print(f"Error: {e}", file=sys.stderr)
- return 1
-
- if args.output:
- args.output.parent.mkdir(parents=True, exist_ok=True)
- args.output.write_text(svg)
- m = re.search(r'width="(\d+)pt" height="(\d+)pt"', svg)
- print(f" minimap {args.output} {m.group(1)}x{m.group(2)}" if m else "")
- else:
- sys.stdout.write(svg)
- return 0
diff --git a/soleprint/atlas2/docgen/emitters/cli_notebook.py b/soleprint/atlas2/docgen/emitters/cli_notebook.py
deleted file mode 100644
index 1ad4905..0000000
--- a/soleprint/atlas2/docgen/emitters/cli_notebook.py
+++ /dev/null
@@ -1,73 +0,0 @@
-""" python3 -m docgen.emitters notebook [-o out.ipynb] [--overlay f.json]
-
- --spec-out FILE write the generated spec (the base), for reading/diffing
- --scaffold FILE write a blank overlay listing every step id
-"""
-
-import argparse
-import json
-import sys
-from pathlib import Path
-
-from ..ir import check
-from ..notebook import dump, from_ir, merge, scaffold
-from .notebook import emit
-
-
-def main(argv=None):
- p = argparse.ArgumentParser(prog="python3 -m docgen.emitters notebook")
- p.add_argument("ir", type=Path)
- p.add_argument("--output", "-o", type=Path)
- 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)
-
- try:
- data = json.loads(args.ir.read_text())
- except (OSError, json.JSONDecodeError) as e:
- print(f"Error: could not read {args.ir}: {e}", file=sys.stderr)
- return 1
- problems = check(data)
- if problems:
- print(f"Error: {args.ir} is not a valid IR ({len(problems)}):", file=sys.stderr)
- for pr in problems[:5]:
- print(f" {pr}", file=sys.stderr)
- return 1
-
- base = from_ir(data, 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)
- if args.output:
- args.output.parent.mkdir(parents=True, exist_ok=True)
- args.output.write_text(text)
- n = len(json.loads(text)["cells"])
- extra = f", {len(drift)} drift" if drift else ""
- print(f" notebook {args.output} {len(spec['steps'])} steps, {n} cells{extra}")
- else:
- sys.stdout.write(text)
- return 0
diff --git a/soleprint/atlas2/docgen/emitters/cli_site.py b/soleprint/atlas2/docgen/emitters/cli_site.py
deleted file mode 100644
index 131b5e5..0000000
--- a/soleprint/atlas2/docgen/emitters/cli_site.py
+++ /dev/null
@@ -1,73 +0,0 @@
-""" python3 -m docgen.emitters site -o DIR [--theme lucid]
-
-Writes index.html, viewer.html, site.css and the graph — self-contained, offline.
-"""
-
-import argparse
-import json
-import sys
-from pathlib import Path
-
-from ..ir import check
-from ..ops import classify
-from ..style import Style, StyleError
-from .site import write
-
-
-def main(argv=None):
- p = argparse.ArgumentParser(prog="python3 -m docgen.emitters site")
- p.add_argument("ir", type=Path)
- p.add_argument("--output", "-o", type=Path, required=True, help="Directory.")
- p.add_argument("--style", default="lucid")
- p.add_argument("--theme", default=None)
- p.add_argument("--title", default="")
- p.add_argument("--no-graph", action="store_true")
- args = p.parse_args(argv)
-
- try:
- data = json.loads(args.ir.read_text())
- except (OSError, json.JSONDecodeError) as e:
- print(f"Error: could not read {args.ir}: {e}", file=sys.stderr)
- return 1
- problems = check(data)
- if problems:
- print(f"Error: {args.ir} is not a valid IR ({len(problems)}):", file=sys.stderr)
- for pr in problems[:5]:
- print(f" {pr}", file=sys.stderr)
- return 1
-
- try:
- style = Style.load(args.style, theme=args.theme)
- except StyleError as e:
- print(f"Error: {e}", file=sys.stderr)
- return 1
-
- 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.
- verdict = classify(data)
- if verdict["emitter"] == "erd":
- from .erd import emit as draw
- (args.output / "graph.svg").write_text(draw(data, style))
- graph_name = "graph.svg"
- elif verdict["emitter"] == "dot":
- from .dot import emit as dot_emit, have_graphviz, render
- if have_graphviz():
- opts = verdict.get("options") or {}
- (args.output / "graph.svg").write_bytes(
- render(dot_emit(data, style, rankdir=opts.get("rankdir")))
- )
- graph_name = "graph.svg"
- else:
- print(" note: graphviz absent — the site is text only", file=sys.stderr)
- else:
- print(f" note: {verdict['kind']} — {verdict['why']}", file=sys.stderr)
- print(" no diagram on the page; the index is the artifact", file=sys.stderr)
-
- files = write(data, style, args.output, graph=graph_name, title=args.title)
- for f in files:
- print(f" site {f}")
- if graph_name:
- print(f" site {args.output / graph_name}")
- return 0
diff --git a/soleprint/atlas2/docgen/emitters/index.py b/soleprint/atlas2/docgen/emitters/index.py
index a90344c..f024798 100644
--- a/soleprint/atlas2/docgen/emitters/index.py
+++ b/soleprint/atlas2/docgen/emitters/index.py
@@ -13,8 +13,8 @@ for positions, or re-deriving containment from edges. It is not, because
`parent` is containment and `kind` is meaning, and that is all a table of
contents needs.
- python3 -m docgen.emitters index ir.json # markdown to stdout
- python3 -m docgen.emitters index ir.json -o x.json # sidebar JSON
+ python3 -m docgen emit index ir.json # markdown to stdout
+ python3 -m docgen emit index ir.json -o x.json # sidebar JSON
## What it reports that a diagram cannot
diff --git a/soleprint/atlas2/docgen/extractors/__main__.py b/soleprint/atlas2/docgen/extractors/__main__.py
deleted file mode 100644
index c223531..0000000
--- a/soleprint/atlas2/docgen/extractors/__main__.py
+++ /dev/null
@@ -1,25 +0,0 @@
-""" python3 -m docgen.extractors [options]"""
-
-import sys
-
-
-def main(argv=None):
- argv = sys.argv[1:] if argv is None else argv
- if argv and argv[0] == "openapi":
- from .openapi_main import main as run
- return run(argv[1:])
- if argv and argv[0] == "code":
- from .code_main import main as run
- return run(argv[1:])
- if argv and argv[0] == "usage":
- from .usage_main import main as run
- return run(argv[1:])
- if argv and argv[0] == "db":
- from .db_main import main as run
- return run(argv[1:])
- from .db_main import main as run # bare form stays the db one, as before
- return run(argv)
-
-
-if __name__ == "__main__":
- sys.exit(main())
diff --git a/soleprint/atlas2/docgen/extractors/code.py b/soleprint/atlas2/docgen/extractors/code.py
index 2251112..7a8dc92 100644
--- a/soleprint/atlas2/docgen/extractors/code.py
+++ b/soleprint/atlas2/docgen/extractors/code.py
@@ -1,7 +1,7 @@
"""
Source in several languages -> IR structure, via **tree-sitter**.
- python3 -m docgen.extractors code --root src/ --lang auto -o ir.json
+ python3 -m docgen extract code --root src/ -o ir.json
Adopts a parser rather than writing one, which is the rule the brief sets and
the reason this handles generics, strings containing braces, nested types and
diff --git a/soleprint/atlas2/docgen/extractors/code_main.py b/soleprint/atlas2/docgen/extractors/code_main.py
deleted file mode 100644
index 1255413..0000000
--- a/soleprint/atlas2/docgen/extractors/code_main.py
+++ /dev/null
@@ -1,39 +0,0 @@
-""" python3 -m docgen.extractors code --root src/ [--ext .cs] [-o ir.json]"""
-
-import argparse
-import json
-import sys
-from pathlib import Path
-
-
-def main(argv=None):
- p = argparse.ArgumentParser(prog="python3 -m docgen.extractors code")
- p.add_argument("--root", "-s", required=True, type=Path)
- p.add_argument("--output", "-o", type=Path)
- p.add_argument("--ext", action="append", default=[],
- help="Limit to these extensions. Default: every registered one.")
- p.add_argument("--exclude", action="append", default=[])
- args = p.parse_args(argv)
-
- from .code import LANGUAGES, MissingParser, extract
-
- try:
- ir = extract(args.root, suffixes=args.ext or None, exclude=tuple(args.exclude))
- except MissingParser as e:
- print(f"Error: {e}", file=sys.stderr)
- return 1
- except (NotADirectoryError, OSError) as e:
- print(f"Error: {e}", file=sys.stderr)
- return 1
-
- text = json.dumps(ir.to_dict(), indent=2) + "\n"
- if args.output:
- args.output.parent.mkdir(parents=True, exist_ok=True)
- args.output.write_text(text)
- from collections import Counter
- c = Counter(n.kind for n in ir.nodes)
- print(f"{len(ir.nodes)} nodes -> {args.output} "
- + " · ".join(f"{v} {k}" for k, v in sorted(c.items(), key=lambda kv: -kv[1])))
- else:
- sys.stdout.write(text)
- return 0
diff --git a/soleprint/atlas2/docgen/extractors/db.py b/soleprint/atlas2/docgen/extractors/db.py
index 8a28f68..46cdd92 100644
--- a/soleprint/atlas2/docgen/extractors/db.py
+++ b/soleprint/atlas2/docgen/extractors/db.py
@@ -9,7 +9,7 @@ reading it means this extractor works for every source modelgen supports
(Django, SQLAlchemy, OpenAPI, CSV/ODS, a live database) without knowing about
any of them.
- python3 -m docgen.extractors.db --schema cfg/sample/.../graphgen/schema.json
+ python3 -m docgen extract db --schema cfg/sample/.../graphgen/schema.json
Connecting to a live database is **not here**. `modelgen from-db --url ...`
reflects via SQLAlchemy's Inspector across dialects and writes the schema.json
diff --git a/soleprint/atlas2/docgen/extractors/db_main.py b/soleprint/atlas2/docgen/extractors/db_main.py
deleted file mode 100644
index 79c52e4..0000000
--- a/soleprint/atlas2/docgen/extractors/db_main.py
+++ /dev/null
@@ -1,31 +0,0 @@
-""" python3 -m docgen.extractors.db --schema path/to/schema.json [-o ir.json]"""
-
-import argparse
-import json
-import sys
-from pathlib import Path
-
-from .db import extract
-
-
-def main(argv=None):
- p = argparse.ArgumentParser(prog="python3 -m docgen.extractors.db")
- p.add_argument("--schema", "-s", required=True, type=Path,
- help="A graphgen-compatible schema.json, as modelgen emits.")
- p.add_argument("--output", "-o", type=Path)
- args = p.parse_args(argv)
-
- try:
- ir = extract(args.schema)
- except (OSError, json.JSONDecodeError, KeyError) as e:
- print(f"Error: could not read {args.schema}: {e}", file=sys.stderr)
- return 1
-
- text = json.dumps(ir.to_dict(), indent=2) + "\n"
- if args.output:
- args.output.parent.mkdir(parents=True, exist_ok=True)
- args.output.write_text(text)
- print(f"{len(ir.nodes)} nodes, {len(ir.edges)} edges -> {args.output}")
- else:
- sys.stdout.write(text)
- return 0
diff --git a/soleprint/atlas2/docgen/extractors/openapi.py b/soleprint/atlas2/docgen/extractors/openapi.py
index 1dea76f..3112684 100644
--- a/soleprint/atlas2/docgen/extractors/openapi.py
+++ b/soleprint/atlas2/docgen/extractors/openapi.py
@@ -5,7 +5,7 @@ Adapts `modelgen/loader/extract/openapi.py`, which already parses OpenAPI 3.x
and Swagger 2.0 and resolves `$ref`. This turns its output into IR nodes; it
does not re-parse anything.
- python3 -m docgen.extractors.openapi --spec petstore.yaml -o ir.json
+ python3 -m docgen extract openapi --spec petstore.yaml -o ir.json
## Why this one matters more than it looks
diff --git a/soleprint/atlas2/docgen/extractors/openapi_main.py b/soleprint/atlas2/docgen/extractors/openapi_main.py
deleted file mode 100644
index 0ec4df1..0000000
--- a/soleprint/atlas2/docgen/extractors/openapi_main.py
+++ /dev/null
@@ -1,29 +0,0 @@
-""" python3 -m docgen.extractors.openapi --spec petstore.yaml [-o ir.json]"""
-
-import argparse
-import json
-import sys
-from pathlib import Path
-
-
-def main(argv=None):
- p = argparse.ArgumentParser(prog="python3 -m docgen.extractors.openapi")
- p.add_argument("--spec", "-s", required=True, type=Path)
- p.add_argument("--output", "-o", type=Path)
- args = p.parse_args(argv)
- from .openapi import extract
-
- try:
- ir = extract(args.spec)
- except (OSError, ImportError, ValueError) as e:
- print(f"Error: {e}", file=sys.stderr)
- return 1
- text = json.dumps(ir.to_dict(), indent=2) + "\n"
- if args.output:
- args.output.parent.mkdir(parents=True, exist_ok=True)
- args.output.write_text(text)
- eps = sum(1 for n in ir.nodes if n.kind == "endpoint")
- print(f"{len(ir.nodes)} nodes ({eps} endpoints), {len(ir.edges)} edges -> {args.output}")
- else:
- sys.stdout.write(text)
- return 0
diff --git a/soleprint/atlas2/docgen/extractors/python/__main__.py b/soleprint/atlas2/docgen/extractors/python/__main__.py
deleted file mode 100644
index 9e18544..0000000
--- a/soleprint/atlas2/docgen/extractors/python/__main__.py
+++ /dev/null
@@ -1,37 +0,0 @@
-""" python3 -m docgen.extractors.python --root PATH [--exclude NAME ...]"""
-
-import argparse
-import json
-import sys
-from pathlib import Path
-
-from . import extract
-
-
-def main(argv=None):
- p = argparse.ArgumentParser(prog="python3 -m docgen.extractors.python")
- p.add_argument("--root", "-s", required=True, type=Path, help="Tree to read.")
- p.add_argument("--output", "-o", type=Path, help="Where to write. Default stdout.")
- p.add_argument("--exclude", action="append", default=[], help="Directory name to skip.")
- args = p.parse_args(argv)
-
- try:
- ir = extract(args.root, exclude=tuple(args.exclude))
- except (NotADirectoryError, OSError) as e:
- print(f"Error: {e}", file=sys.stderr)
- return 1
-
- text = json.dumps(ir.to_dict(), indent=2) + "\n"
- if args.output:
- args.output.parent.mkdir(parents=True, exist_ok=True)
- args.output.write_text(text)
- skipped = sum(1 for n in ir.nodes if n.attrs.get("error"))
- print(f"{len(ir.nodes)} nodes, {len(ir.edges)} edges -> {args.output}"
- + (f" ({skipped} file(s) unparsed)" if skipped else ""))
- else:
- sys.stdout.write(text)
- return 0
-
-
-if __name__ == "__main__":
- sys.exit(main())
diff --git a/soleprint/atlas2/docgen/extractors/usage.py b/soleprint/atlas2/docgen/extractors/usage.py
index c4ef41f..655f90a 100644
--- a/soleprint/atlas2/docgen/extractors/usage.py
+++ b/soleprint/atlas2/docgen/extractors/usage.py
@@ -5,7 +5,7 @@ Reads a **HAR** file — the format browser devtools, mitmproxy, Charles and
Insomnia all export. Standard, JSON, stdlib-parseable, and already sitting on
most people's disk after ten minutes of using the thing they want documented.
- python3 -m docgen.extractors usage --har session.har -o ir.json
+ python3 -m docgen extract usage --har session.har -o ir.json
## Why this exists
diff --git a/soleprint/atlas2/docgen/extractors/usage_main.py b/soleprint/atlas2/docgen/extractors/usage_main.py
deleted file mode 100644
index f6aedd4..0000000
--- a/soleprint/atlas2/docgen/extractors/usage_main.py
+++ /dev/null
@@ -1,32 +0,0 @@
-""" python3 -m docgen.extractors usage --har session.har [-o ir.json]"""
-
-import argparse
-import json
-import sys
-from pathlib import Path
-
-
-def main(argv=None):
- p = argparse.ArgumentParser(prog="python3 -m docgen.extractors usage")
- p.add_argument("--har", "-s", required=True, type=Path,
- help="A HAR recording, as devtools/mitmproxy/Charles export.")
- p.add_argument("--output", "-o", type=Path)
- args = p.parse_args(argv)
- from .usage import extract
-
- try:
- ir = extract(args.har)
- except (OSError, json.JSONDecodeError, KeyError) as e:
- print(f"Error: could not read {args.har}: {e}", file=sys.stderr)
- return 1
- text = json.dumps(ir.to_dict(), indent=2) + "\n"
- if args.output:
- args.output.parent.mkdir(parents=True, exist_ok=True)
- args.output.write_text(text)
- eps = sum(1 for n in ir.nodes if n.kind == "endpoint")
- ops = sum(1 for n in ir.nodes if n.kind == "operation")
- print(f"{len(ir.nodes)} nodes ({eps} endpoints, {ops} graphql), "
- f"{len(ir.edges)} sequence edges -> {args.output}")
- else:
- sys.stdout.write(text)
- return 0
diff --git a/soleprint/atlas2/docgen/fixtures/shop.json b/soleprint/atlas2/docgen/fixtures/shop.json
new file mode 100644
index 0000000..2349ea2
--- /dev/null
+++ b/soleprint/atlas2/docgen/fixtures/shop.json
@@ -0,0 +1,12 @@
+{
+ "source": "postgresql",
+ "models": {
+ "users": {"doc": "People who buy.", "fields": {"id": {"type": "int", "pk": true}, "email": {"type": "str"}}},
+ "orders": {"doc": "One purchase.", "fields": {"id": {"type": "int", "pk": true}, "user": {"type": "FK:users"}, "total": {"type": "float"}}},
+ "items": {"doc": "A line on an order.", "fields": {"id": {"type": "int", "pk": true}, "order": {"type": "FK:orders"}, "sku": {"type": "str"}}}
+ },
+ "relationships": [
+ {"from": "orders", "to": "users", "from_field": "user"},
+ {"from": "items", "to": "orders", "from_field": "order"}
+ ]
+}
diff --git a/soleprint/atlas2/docgen/ir/__main__.py b/soleprint/atlas2/docgen/ir/__main__.py
deleted file mode 100644
index c052842..0000000
--- a/soleprint/atlas2/docgen/ir/__main__.py
+++ /dev/null
@@ -1,8 +0,0 @@
-""" python3 -m docgen.ir — validate a document at the boundary."""
-
-import sys
-
-from .validate import main
-
-if __name__ == "__main__":
- sys.exit(main())
diff --git a/soleprint/atlas2/docgen/ir/validate.py b/soleprint/atlas2/docgen/ir/validate.py
index 9515d5c..b60de75 100644
--- a/soleprint/atlas2/docgen/ir/validate.py
+++ b/soleprint/atlas2/docgen/ir/validate.py
@@ -5,7 +5,7 @@ Called wherever the IR crosses between layers: after an extractor writes one,
before an emitter reads one. That is the whole point of having one format — the
check is in one place instead of every consumer defending itself.
- python3 -m docgen.ir path/to/ir.json
+ python3 -m docgen validate path/to/ir.json
Stdlib only. This is not a general JSON Schema engine and does not want to be;
it reads the field lists **out of `schema.json`** so the two cannot drift, then
@@ -23,7 +23,6 @@ that produce a broken diagram.
import json
import re
-import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
@@ -276,37 +275,3 @@ def check_model_matches_schema() -> list[str]:
for extra in actual - declared:
problems.append(f"{cls.__name__} has {extra!r}; schema does not declare it")
return problems
-
-
-def main(argv=None) -> int:
- argv = sys.argv[1:] if argv is None else argv
- if not argv:
- print("usage: python3 -m docgen.ir ", file=sys.stderr)
- return 2
-
- drift = check_model_matches_schema()
- if drift:
- print("model.py and schema.json disagree:", file=sys.stderr)
- for d in drift:
- print(f" {d}", file=sys.stderr)
- return 1
-
- path = Path(argv[0])
- try:
- data = json.loads(path.read_text())
- except (OSError, json.JSONDecodeError) as e:
- print(f"Error: could not read {path}: {e}", file=sys.stderr)
- return 1
-
- problems = check(data)
- if problems:
- print(f"{path}: {len(problems)} problem(s)", file=sys.stderr)
- for p in problems:
- print(f" {p}", file=sys.stderr)
- return 1
-
- print(
- f"{path}: ok — {len(data['nodes'])} nodes, {len(data['edges'])} edges, "
- f"schema v{data['meta'].get('schema_version')}"
- )
- return 0
diff --git a/soleprint/atlas2/docgen/ops/__init__.py b/soleprint/atlas2/docgen/ops/__init__.py
index 9a690ec..693db0f 100644
--- a/soleprint/atlas2/docgen/ops/__init__.py
+++ b/soleprint/atlas2/docgen/ops/__init__.py
@@ -2,7 +2,7 @@
Views over the IR. Filtering is not an emitter concern — it belongs here, once,
so the index, the diagram and the diff all narrow the same way.
- python3 -m docgen.ops ir.json --drop-stdlib --only class -o smaller.json
+ python3 -m docgen view ir.json --drop-stdlib --only class -o smaller.json
"""
from .filter import (
diff --git a/soleprint/atlas2/docgen/pyproject.toml b/soleprint/atlas2/docgen/pyproject.toml
new file mode 100644
index 0000000..cc06c2e
--- /dev/null
+++ b/soleprint/atlas2/docgen/pyproject.toml
@@ -0,0 +1,32 @@
+[project]
+name = "docgen"
+version = "0.2.0"
+description = "Source artifacts into one graph IR, then into diagrams, indexes, notebooks and sites"
+readme = "README.md"
+# 3.11 for tomllib, which reads run files. Nothing else needs more than 3.10.
+requires-python = ">=3.11"
+# None. The whole structural path — extraction, the IR, views, every emitter but
+# the rendered diagram, the book and its checks — is the stdlib. Everything below
+# is optional, and the suite skips rather than fails without it.
+dependencies = []
+
+[dependency-groups]
+# C# and TypeScript, through tree-sitter. Python never needs it: it uses `ast`.
+code = [
+ "tree-sitter>=0.23",
+ "tree-sitter-c-sharp>=0.23",
+ "tree-sitter-typescript>=0.23",
+ "tree-sitter-python>=0.23",
+]
+# Harvesting a theme from exported SVG/PDF diagrams (style/extract.py).
+harvest = ["lxml>=5.0"]
+# Reading OpenAPI. Also needs the reference repo's modelgen — see reference.py.
+openapi = ["pyyaml>=6.0"]
+# Sanctioned experiments in lab/. Nothing outside lab/ imports it.
+lab = ["networkx>=3.2"]
+
+[tool.uv]
+# A folder of modules run in place, not an installable package — the same choice
+# dataconvert makes. Copy the folder anywhere and it still works; the lock pins
+# what the optional groups resolve to.
+package = false
diff --git a/soleprint/atlas2/docgen/selftest.py b/soleprint/atlas2/docgen/selftest.py
index 38cc3bd..4d3ab2e 100644
--- a/soleprint/atlas2/docgen/selftest.py
+++ b/soleprint/atlas2/docgen/selftest.py
@@ -87,6 +87,17 @@ FIXTURE = {
}
+
+# docgen's own Python — what a source scan should read. Not `HERE.rglob`: once
+# `make sync` has run, `.venv/` under this folder holds thousands of third-party
+# modules, and the standalone check reported lxml's own imports as docgen's.
+NOT_OURS = {"__pycache__", ".venv", "venv", "node_modules", "out"}
+
+
+def own_py_files():
+ return sorted(p for p in HERE.rglob("*.py")
+ if not NOT_OURS & set(p.relative_to(HERE).parts))
+
def check(name, condition, detail=""):
(PASS if condition else FAIL).append(name)
print(f" {'ok ' if condition else 'FAIL'} {name}"
@@ -1875,7 +1886,7 @@ def _standalone():
stdlib = set(sys.stdlib_module_names)
own = {PKG}
- files = sorted(p for p in HERE.rglob("*.py") if "__pycache__" not in p.parts)
+ files = own_py_files()
foreign, seam_leaks = [], []
for path in files:
@@ -2005,6 +2016,309 @@ def _standalone():
_standalone()
+
+# --------------------------------------------------------------------------
+print("\n17. one command line, and library code that has none")
+
+
+def _layout():
+ """Library packages hold library code; the command line lives in cli/.
+
+ The rule exists because two kinds of file in one folder — `cli_dot.py` next
+ to `dot.py` — make every folder answer two questions, and the command-line
+ half is the half that grows copies. It decays by accretion: one convenient
+ `argparse` in a library module and the split is gone. So it is read from the
+ source rather than trusted.
+ """
+ cli_mod = __import__(f"{PKG}.cli", fromlist=["*"])
+
+ # Where command-line code may live. lab/ is sanctioned experiments, and the
+ # suite is itself a script.
+ allowed = {"cli", "lab"}
+ offenders = []
+ for path in own_py_files():
+ rel = path.relative_to(HERE)
+ if rel.parts[0] in allowed or rel.as_posix() in ("__main__.py", "selftest.py"):
+ continue
+ tree = ast.parse(path.read_text())
+ for node in ast.walk(tree):
+ names = []
+ if isinstance(node, ast.Import):
+ names = [a.name for a in node.names]
+ elif isinstance(node, ast.ImportFrom) and not node.level:
+ names = [node.module or ""]
+ if "argparse" in names:
+ offenders.append(rel.as_posix())
+ check(
+ "nothing outside cli/ parses a command line",
+ not offenders,
+ f"{sorted(set(offenders))} import argparse — command-line code belongs in cli/",
+ )
+
+ stray = sorted(
+ p.relative_to(HERE).as_posix() for p in own_py_files()
+ if (p.name == "__main__.py" and p.parent != HERE)
+ or p.name.startswith("cli_") or p.stem.endswith("_main")
+ )
+ check(
+ "one entry point — no __main__.py, cli_*.py or *_main.py in the packages",
+ not stray,
+ f"found {stray}",
+ )
+
+ missing = []
+ for name, (module, _) in cli_mod.COMMANDS.items():
+ try:
+ mod = __import__(f"{PKG}.cli.{module}", fromlist=["*"])
+ except ImportError as e:
+ missing.append(f"{name}: {e}")
+ continue
+ if not callable(getattr(mod, "main", None)):
+ missing.append(f"{name}: no main()")
+ check("every listed command has a module with main()", not missing, "; ".join(missing))
+
+ # How a failure reaches the user is part of the interface: one line on
+ # stderr, exit 1, never a traceback. Run as a real process, the way a shell
+ # or a Makefile sees it.
+ env = dict(__import__("os").environ, PYTHONPATH=str(HERE.parent))
+ run = lambda *a: _sub.run([sys.executable, "-m", PKG, *a], capture_output=True,
+ text=True, env=env)
+ bare = run()
+ check("with no command it prints the commands and exits 2",
+ bare.returncode == 2 and "emit" in bare.stdout and "run" in bare.stdout,
+ f"exit {bare.returncode}")
+ bad = run("validate", str(Path(tmp.name) / "no-such.json"))
+ check(
+ "an expected failure is one line on stderr, exit 1, no traceback",
+ bad.returncode == 1 and bad.stderr.startswith("Error:")
+ and "Traceback" not in bad.stderr and bad.stderr.count("\n") == 1,
+ f"exit {bad.returncode}: {bad.stderr[:120]!r}",
+ )
+
+ # The structure-picks-the-drawing branch used to be written three times —
+ # the `auto` command, the `site` command, the book. Now it is `draw()`, and
+ # the book reaching past it to the concrete emitters would be the first copy.
+ # Parsed, not grepped, for the reason section 2 gives: the docstring naming
+ # `emitters.dot` is prose, and the first version of this check failed on it.
+ imported = set()
+ for node in ast.walk(ast.parse((HERE / "book" / "build.py").read_text())):
+ if isinstance(node, ast.ImportFrom) and node.module:
+ imported |= {f"{node.module}.{a.name}" for a in node.names}
+ check(
+ "the book chooses its drawing through draw(), not by hand",
+ "emitters.auto.draw" in imported
+ and not any(m.startswith(("emitters.erd", "emitters.dot")) for m in imported),
+ f"imports {sorted(m for m in imported if m.startswith('emitters'))}",
+ )
+
+
+_layout()
+
+
+# --------------------------------------------------------------------------
+print("\n18. run files — every book, rebuilt with one command")
+
+
+def _runfiles():
+ cfg = __import__(f"{PKG}.book.config", fromlist=["*"])
+ build_mod = __import__(f"{PKG}.book.build", fromlist=["*"])
+ ref_mod = __import__(f"{PKG}.reference", fromlist=["*"])
+ import os as _os
+
+ base = Path(tmp.name) / "runfiles"
+ (base / "src" / "app").mkdir(parents=True, exist_ok=True)
+ (base / "src" / "app" / "__init__.py").write_text('"""App."""\n')
+ (base / "src" / "app" / "models.py").write_text('"""Models."""\n\n\nclass User:\n pass\n')
+ (base / "shop.json").write_text((HERE / "fixtures" / "shop.json").read_text())
+
+ def write(text, name="docgen.toml"):
+ path = base / name
+ path.write_text(text)
+ return path
+
+ def problems(text):
+ try:
+ cfg.load(write(text, "bad.toml"))
+ except cfg.ConfigError as e:
+ return e.problems
+ return []
+
+ example = cfg.load(HERE / "docgen.example.toml")
+ check(
+ "the shipped example loads",
+ [b.name for b in example.books] == ["extractors", "schema", "api"],
+ f"got {[b.name for b in example.books]}",
+ )
+
+ good = write(
+ '[defaults]\nout = "books"\nexclude = ["vendor"]\n\n'
+ '[[book]]\nname = "app"\nroot = "src"\n\n'
+ '[[book]]\nname = "shop"\nschema = "shop.json"\n\n'
+ '[[book]]\nname = "narrow"\nroot = "src/app"\nout = "elsewhere/narrow"\nexclude = []\n'
+ )
+ # Relative to the FILE. Loaded from another working directory, a run file
+ # that resolved against cwd would build different books from the same text.
+ previous = _os.getcwd()
+ try:
+ _os.chdir(Path(tmp.name))
+ rf = cfg.load(good)
+ finally:
+ _os.chdir(previous)
+ by = {b.name: b for b in rf.books}
+ check(
+ "paths resolve against the run file, not the working directory",
+ by["app"].source == base / "src" and by["app"].out == base / "books" / "app",
+ f"source {by['app'].source}, out {by['app'].out}",
+ )
+ check("a book's own out overrides /",
+ by["narrow"].out == base / "elsewhere" / "narrow")
+ check(
+ "a book's value replaces the default — exclude included, no merging",
+ by["app"].exclude == ("vendor",) and by["narrow"].exclude == (),
+ f"app {by['app'].exclude}, narrow {by['narrow'].exclude}",
+ )
+ check(
+ "an inherited exclude does not apply to a non-tree source, and is not an error",
+ by["shop"].exclude == () and by["shop"].kind == "db",
+ )
+
+ # Refused, not ignored — and every problem at once, not the first.
+ found = problems(
+ 'colour = "red"\n[defaults]\nstyel = "x"\n\n'
+ '[[book]]\nname = "a"\nroot = "src"\nexlude = ["x"]\n'
+ )
+ check(
+ "unknown keys are refused at every level, all reported together",
+ len(found) == 3 and all("unknown" in f for f in found),
+ f"got {found}",
+ )
+ check(
+ "a book names exactly one source",
+ any("exactly one source" in f for f in problems(
+ '[[book]]\nname = "a"\nroot = "src"\nschema = "shop.json"\n')),
+ )
+ check(
+ "an explicit exclude on a non-tree source is refused",
+ any("only applies to a root" in f for f in problems(
+ '[[book]]\nname = "a"\nschema = "shop.json"\nexclude = ["x"]\n')),
+ )
+ # The next run would read the last run's output: a rebuild that changes on
+ # every rebuild.
+ check(
+ "a book written inside the tree it reads is refused",
+ any("inside the tree it reads" in f for f in problems(
+ '[[book]]\nname = "a"\nroot = "src"\nout = "src/book"\n')),
+ )
+ check(
+ "two books writing to one directory are refused",
+ any("both write to" in f for f in problems(
+ '[[book]]\nname = "a"\nroot = "src"\nout = "same"\n\n'
+ '[[book]]\nname = "b"\nschema = "shop.json"\nout = "same"\n')),
+ "the second would clear the first on every run, silently",
+ )
+ try:
+ rf.select(["app", "nope"])
+ selected = False
+ except cfg.ConfigError:
+ selected = True
+ check("--only with an unknown name is an error, not an empty run", selected)
+
+ # The caller's env beats the file — rig's precedence rule for every setting.
+ saved = _os.environ.pop(ref_mod.ENV_VAR, None)
+ try:
+ with_ref = cfg.load(write(
+ f'reference = "{base}"\n[[book]]\nname = "a"\nroot = "src"\n', "ref.toml"))
+ check("a run file can name the reference repo",
+ cfg.apply_reference(with_ref) == str(base)
+ and _os.environ.get(ref_mod.ENV_VAR) == str(base))
+ _os.environ[ref_mod.ENV_VAR] = "/from/the/environment"
+ check("but the environment wins over the file",
+ cfg.apply_reference(with_ref) is None
+ and _os.environ[ref_mod.ENV_VAR] == "/from/the/environment")
+ finally:
+ _os.environ.pop(ref_mod.ENV_VAR, None)
+ if saved is not None:
+ _os.environ[ref_mod.ENV_VAR] = saved
+
+ # -- running it ----------------------------------------------------------
+ broken = write(
+ '[[book]]\nname = "app"\nroot = "src"\nout = "run/app"\n\n'
+ '[[book]]\nname = "bad"\nhar = "not-a-har.har"\nout = "run/bad"\n\n'
+ '[[book]]\nname = "shop"\nschema = "shop.json"\nout = "run/shop"\n',
+ "run.toml",
+ )
+ (base / "not-a-har.har").write_text("this is not json")
+ results = {r["name"]: r for r in cfg.run(cfg.load(broken), check=True)}
+ check(
+ "one broken book does not stop the others",
+ results["app"]["ok"] and results["shop"]["ok"]
+ and not results["bad"]["ok"] and results["bad"]["error"],
+ {k: (v["ok"], v["error"]) for k, v in results.items()},
+ )
+ check("each built book passed its own level", not results["app"]["checks_failed"])
+
+ # Rerunning is the point of a run file, so a rerun must not leave the last
+ # run's artifacts beside the new ledger, and must not touch what a person wrote.
+ app = base / "run" / "app"
+ (app / "steps" / "stale.svg").write_text("")
+ (app / "checks.py").write_text("def checks(book, check, note, skip):\n pass\n")
+ again = {r["name"]: r for r in cfg.run(cfg.load(broken), ["app"])}
+ check(
+ "a rebuild removes the previous build's outputs",
+ again["app"]["ok"] and not (app / "steps" / "stale.svg").exists(),
+ "a stale graph.svg would go on showing in the site, unlisted in the ledger",
+ )
+ check("...and keeps the hand-written checks.py", (app / "checks.py").exists())
+
+ foreign = base / "not-a-book"
+ foreign.mkdir(exist_ok=True)
+ (foreign / "steps").mkdir(exist_ok=True)
+ (foreign / "site").mkdir(exist_ok=True)
+ check(
+ "clear() never touches a directory it did not write",
+ build_mod.clear(foreign) == [] and (foreign / "steps").exists(),
+ "a mistyped `out` must not delete somebody's `site/`",
+ )
+
+ # -- the environment the lock pins ---------------------------------------
+ import tomllib as _toml
+ project = _toml.loads((HERE / "pyproject.toml").read_text())
+ check(
+ "pyproject declares no required dependencies",
+ project["project"]["dependencies"] == [],
+ "the structural path is the stdlib; everything else is an optional group",
+ )
+ check(
+ "it requires the Python that reads run files",
+ project["project"]["requires-python"] == ">=3.11",
+ "tomllib is 3.11+",
+ )
+ # A module the suite allows docgen to import must be a dependency somebody
+ # can install from the lock, or the allow-list and pyproject have drifted.
+ declared = {d.split(">")[0].split("=")[0].strip().lower().replace("-", "_")
+ for group in project["dependency-groups"].values() for d in group}
+ import_to_dist = {"tree_sitter": "tree_sitter", "tree_sitter_c_sharp": "tree_sitter_c_sharp",
+ "tree_sitter_typescript": "tree_sitter_typescript",
+ "tree_sitter_python": "tree_sitter_python",
+ "lxml": "lxml", "yaml": "pyyaml", "networkx": "networkx"}
+ undeclared = sorted(m for m, dist in import_to_dist.items() if dist not in declared)
+ check("every optional import is a declared dependency group", not undeclared,
+ f"{undeclared} are allowed but not in pyproject")
+ lock = HERE / "uv.lock"
+ locked = set()
+ if lock.exists():
+ locked = {pkg["name"].replace("-", "_")
+ for pkg in _toml.loads(lock.read_text()).get("package", [])}
+ check(
+ "uv.lock pins every declared dependency",
+ lock.exists() and declared <= locked,
+ f"not in the lock: {sorted(declared - locked)} — run `make lock`",
+ )
+
+
+_runfiles()
+
+
print(f"{len(PASS)} passed, {len(FAIL)} failed, {len(SKIP)} skipped")
if FAIL:
print("\nfailed:")
diff --git a/soleprint/atlas2/docgen/uv.lock b/soleprint/atlas2/docgen/uv.lock
new file mode 100644
index 0000000..8e11136
--- /dev/null
+++ b/soleprint/atlas2/docgen/uv.lock
@@ -0,0 +1,321 @@
+version = 1
+revision = 3
+requires-python = ">=3.11"
+
+[[package]]
+name = "docgen"
+version = "0.2.0"
+source = { virtual = "." }
+
+[package.dev-dependencies]
+code = [
+ { name = "tree-sitter" },
+ { name = "tree-sitter-c-sharp" },
+ { name = "tree-sitter-python" },
+ { name = "tree-sitter-typescript" },
+]
+harvest = [
+ { name = "lxml" },
+]
+lab = [
+ { name = "networkx" },
+]
+openapi = [
+ { name = "pyyaml" },
+]
+
+[package.metadata]
+
+[package.metadata.requires-dev]
+code = [
+ { name = "tree-sitter", specifier = ">=0.23" },
+ { name = "tree-sitter-c-sharp", specifier = ">=0.23" },
+ { name = "tree-sitter-python", specifier = ">=0.23" },
+ { name = "tree-sitter-typescript", specifier = ">=0.23" },
+]
+harvest = [{ name = "lxml", specifier = ">=5.0" }]
+lab = [{ name = "networkx", specifier = ">=3.2" }]
+openapi = [{ name = "pyyaml", specifier = ">=6.0" }]
+
+[[package]]
+name = "lxml"
+version = "6.1.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/23/ad/28ecd7cb894d172f3c9c80a075eeeb2017ac62e3632cee05a5f9493547eb/lxml-6.1.3.tar.gz", hash = "sha256:45222d94ddd511536f3b2f7d9deae3b2339b4ce0f075f1ca25703b07cad9dd21", size = 4211198, upload-time = "2026-09-02T14:48:02.287Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/96/f1/95133bde7af7afb1f5ba6090b674d826b7a518318bba54bbbb633b27865a/lxml-6.1.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c66f858b82497173f73366795fc6ee8171620e75a338506d6b2e7bc16f5fca11", size = 8563141, upload-time = "2026-09-02T14:46:42.334Z" },
+ { url = "https://files.pythonhosted.org/packages/80/54/5a79ee2181ac773ee13e48205411845feec69e1c3d097e985c1343171712/lxml-6.1.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:032a0a97eed428bd143c75a11118238546424ceb2fa311cca5f073aa44658dc4", size = 4613690, upload-time = "2026-09-02T14:46:45.253Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/29/8c24672f56807f119312f073f24204368574bd16b384ede861b5104b3a2b/lxml-6.1.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4a579dfb9c835f8ab47f4b8ed33440cbc75b806b73297208e6ec2a33e903740b", size = 4935630, upload-time = "2026-09-02T14:46:48.071Z" },
+ { url = "https://files.pythonhosted.org/packages/71/69/ce2436d854c848c19fc9287143991f3fc76b8b4e9a0dbba8452e51dff264/lxml-6.1.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:49fbc2682a9306135b7ec49e93f97f9c26689b9b7f96ed2742d8d6497e994d13", size = 5079033, upload-time = "2026-09-02T14:46:50.483Z" },
+ { url = "https://files.pythonhosted.org/packages/91/ec/b66f66f6499ad800265d57540b51e6632e3232d3526f42f2f8fd4b14e0ea/lxml-6.1.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ea2c01cdb16dc12156e455007c406dfaaece0c89aa4ba0e3b47586779f951d41", size = 5012298, upload-time = "2026-09-02T14:46:52.603Z" },
+ { url = "https://files.pythonhosted.org/packages/94/2a/25d128872f4d51753542bfc3feb482c2ea7c8a2d6d81a0bc5c6a00779ed4/lxml-6.1.3-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:527195c188d7d0af748cd48d220ab8cdc5cb99be3d49ac4d9be7324d8abf9bc0", size = 5211431, upload-time = "2026-09-02T14:46:54.722Z" },
+ { url = "https://files.pythonhosted.org/packages/75/b2/0a41bbef074a556110f84fafb6d8c2998293c7d3bfbe1ce74515bc65393b/lxml-6.1.3-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:20384c2bbcbf87180c8c61eb60869699c1ec0cd09b62cfd13804022d860b0867", size = 5343417, upload-time = "2026-09-02T14:46:57.46Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/cd/16116c3f91791aeeeab1cbe6e7eb6e646f127be7b0158b262eb526a21a0c/lxml-6.1.3-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:424aa5657141d306ba9ad1baab4b2c0a0719040075ee6c66aee9bb2dea2b5054", size = 4673219, upload-time = "2026-09-02T14:46:59.604Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/bb/4dff849f443ef70221676aec938bc41e8bae6430aa2ca13b041319e14b98/lxml-6.1.3-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4736e6c87e603146d8949d8501da621ad20c31015060d3fcf95ace2859f3e3e6", size = 5281246, upload-time = "2026-09-02T14:47:02.375Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/ac/4aa7dd059420bfd35278c7fe819e9d319ee36a0453b7bbde1907a7832d91/lxml-6.1.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6374e9e382e5a98c9c5e66d41b357b470da1c54bce30f17f9dc4bcc58436cc1c", size = 5055451, upload-time = "2026-09-02T14:47:05.883Z" },
+ { url = "https://files.pythonhosted.org/packages/de/44/20d90cf6f4234de9cd9eeb4f519419885fdb087fa80d073c7b57be342021/lxml-6.1.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:22eec57e26c418cde02c051ce9914a365e52a7f135a565c6f0480242aeebab48", size = 4722694, upload-time = "2026-09-02T14:47:08.461Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/0e/6bee12325e53dd6613fe1e107def07583b6182ade03e94bfef8976622e44/lxml-6.1.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8753b8d51dbc86fd335ee31fcf7f3658e9f5c016d4edfb23f76ad295f4b8c9d0", size = 5269179, upload-time = "2026-09-02T14:47:10.647Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/5d/54d269ce5cd0787c0424d9cef449ee794d4097725d13dd2acd6181c44e9c/lxml-6.1.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:207dfc3d47cf0e575e643bbc140dacc8863b39abaa1e5307cd64c7f2365b8a12", size = 5235559, upload-time = "2026-09-02T14:47:13.932Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/f7/5a3095f187f1bec293591616a1677781acc265c5b313c009f8a19c471a09/lxml-6.1.3-cp311-cp311-win32.whl", hash = "sha256:18293f8a8d8b6a8e71ef37706b659e3846a4261232158167b1ddf35f6994f633", size = 3600377, upload-time = "2026-09-02T14:47:15.957Z" },
+ { url = "https://files.pythonhosted.org/packages/45/5a/15531a0d307c96282fe8b639b3d74e8bd783e4ab4cb2b0781146ac4161b8/lxml-6.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:7ae4949f212a53b007dbc355884fda122545c5764a54256c9217e419a62a6559", size = 4032700, upload-time = "2026-09-02T14:47:18.566Z" },
+ { url = "https://files.pythonhosted.org/packages/12/f9/8de76314955545ceaaa7c0305017b8aaa217905dee59c62c0e2c1e44a68f/lxml-6.1.3-cp311-cp311-win_arm64.whl", hash = "sha256:2123e5aa075ac20d23c7af489255efd129cbfe190dbe88fd42598cc9df3199b6", size = 3674431, upload-time = "2026-09-02T14:47:22.186Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/1f/a180b57d9eeabaab77f9d5aa30356898ea749c4795596a8f66d1eb6bef2e/lxml-6.1.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c0710ac085a157b593c38fbcacd950f15c4afa8e2057527185875ab302752bc", size = 8602094, upload-time = "2026-09-02T14:47:26.054Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/25/070c92013a1c029a602b03560d68772313d918268667fa993da7961759c9/lxml-6.1.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:623c8799c17128753c65699f1c3aa32402657393a9ad6db09ed8b98ddf76611d", size = 4638308, upload-time = "2026-09-02T14:47:29.587Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/1c/722e88883173097a1a375153e3c2447eba3060d0231522cf6596e99f4195/lxml-6.1.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f683dc6300317700025e41d89a43e0276692ded16113a3c43eab704d605c58e5", size = 4939696, upload-time = "2026-09-02T14:47:32.997Z" },
+ { url = "https://files.pythonhosted.org/packages/db/36/aa413bc214dc4f785ad2b2ddd8cc99aae7062d49ab155e91e6011af00daf/lxml-6.1.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:379f8a75cf6eb7eef0af074b55f49ab73b868388a98de14646abcdfa4564bb11", size = 5105247, upload-time = "2026-09-02T14:47:36.734Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/a0/a1f7f1313795bfec67b77f01ef3b1128d49f2d7f66a8413fa55d47f4e25f/lxml-6.1.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b37772102d44bb6628186accca3a121b1fa3a6b3d97518a8c29a5229ca4c0d0a", size = 5011915, upload-time = "2026-09-02T14:47:39.846Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/78/840e7e3f1d0cc7a5cfac5d8505b97e25b6427fd774ac4bae672aaebfb4b5/lxml-6.1.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ddcf547bea2aee967d6a77779376a45e77e610e8465147a1f3d7e20d539d6e32", size = 5638175, upload-time = "2026-09-02T14:47:43.644Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/20/e022dbc6b4753a9bc9fc5fb28a27163430c1731b9913997f6544c1b2518c/lxml-6.1.3-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:909f4e927bb051f7740d6367285fc60cdcfdaf0258c2dba4ff5ba7eadadc250c", size = 5244675, upload-time = "2026-09-02T14:47:47.635Z" },
+ { url = "https://files.pythonhosted.org/packages/99/83/82cde81d2b5eb38d1539fdfdf318abdd014a7e604f4df01c9cd3deb18f2a/lxml-6.1.3-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:a5c18810318303ce9afb3f95e2ddb54834f96fa699a8600433fd5a93dcf44c56", size = 5358205, upload-time = "2026-09-02T14:47:50.306Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/a1/f3b057371c8cb29f2a9c9c44ea320592446e40b74a4b0af68c3d8e65bc73/lxml-6.1.3-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:3e42265103fb385d8642a78672edf376c6f7e1d3598a7a4f9cb1278f2f6b5f6f", size = 4704495, upload-time = "2026-09-02T14:47:53.251Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/a4/230eb28be5d412152ffc3c679b51fe1aeede5a53f3a8eb6e9748f2f4754f/lxml-6.1.3-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:21402998e4b78e7cce237d2788841aaa21ac9a4d1574d04dc2d12ee41ae807b5", size = 5255117, upload-time = "2026-09-02T14:47:55.963Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/18/1969f56763af24ce42ea156007b0b2d73fddea552e283b2010416394f0f4/lxml-6.1.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:38fc4e4e4e084e0bd491949482527d406788045c546d4f8789e93fc527b91385", size = 5054424, upload-time = "2026-09-02T14:47:58.131Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/d4/2a90acc1f6fabaa3a8db9340437822bd8d041b205d626a4b3e8621aaa390/lxml-6.1.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5609efdb0d3c95499c00046bc53648b3482ec2175b5503d6e611b3f0555dc71d", size = 4785572, upload-time = "2026-09-02T14:48:01.029Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/1e/b90e845b1dcd0f2f3f26b98283d857f25909223aacd265eee032c34ab8b1/lxml-6.1.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:97ce49699d87ebf8aad631b55d65b33219a4f1bfefbbf5bff19dc9af160aeaf9", size = 5656516, upload-time = "2026-09-02T14:48:03.419Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/ab/0a1b802c57f3fba5c4efd77d5c6b78adaa8f7b681f0c90456b140fe8bf6c/lxml-6.1.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:48542c9acba9ff9450bd18d871d2c2c8787fdb283572b623d206f1b927cd7d9e", size = 5245982, upload-time = "2026-09-02T14:48:06.109Z" },
+ { url = "https://files.pythonhosted.org/packages/da/ee/2c016fbceb3778137459292538d9dfa7e3ad9070fe409c15254ddd90d2cc/lxml-6.1.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c55e71a9b1db1f107efb60da49c093689b74c5c31a708e5379e2fd9439d4fbb5", size = 5267340, upload-time = "2026-09-02T14:48:08.374Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/b1/736d18fd6f0835761923b7bac1f0c27d60c1200384e9093f05d8c5100525/lxml-6.1.3-cp312-cp312-win32.whl", hash = "sha256:b3ff39654f0ce6ebd4db154211136dbe7e8157bcc3bed2344c87f32c7c6ecb6c", size = 3602606, upload-time = "2026-09-02T14:48:10.384Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/5b/6ed903e4e6278a020c8a6f0dbbe78030d041840a6b4a64ea441a1e414077/lxml-6.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:3e9a00d1c2c30936f7add097c41afc5da6556c580909104aafd382cac92a855c", size = 4005999, upload-time = "2026-09-02T14:48:12.51Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/1b/7bcebb7b6332cb3ae85e9c13b139adb6f23f75c71d84041c56a5005d9a29/lxml-6.1.3-cp312-cp312-win_arm64.whl", hash = "sha256:1aeca87830c4fe649dcf93fe2b059525b71c72587f21be4ae4af7103082a79fa", size = 3666631, upload-time = "2026-09-02T14:48:14.567Z" },
+ { url = "https://files.pythonhosted.org/packages/52/05/3ef45db776baea068044c799bbba68f3ca00a440c0e930a17c572f3d9639/lxml-6.1.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:3a48093cdb058a93af842ede9703520e810b05dcd0fc6d7190a06376c3bfb6bd", size = 8590357, upload-time = "2026-09-02T14:48:17.413Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/a5/eee2fc77eee5ea68e4a4334b1def1781a3beaeefd3d98e81b4a38dc447b7/lxml-6.1.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:887c021d9a977cff89cb273047c1352997b772a8908a25c21836861f69b92be1", size = 4632616, upload-time = "2026-09-02T14:48:20.745Z" },
+ { url = "https://files.pythonhosted.org/packages/35/42/df27b56848acd29d8a720acc28977911aab36f2a09df4208d5502e887415/lxml-6.1.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:611a51e61c92f62345a50b0035df6fc0d678f9299f33728826d831598862f59d", size = 4936186, upload-time = "2026-09-02T14:48:22.94Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/8d/8a7b91df0b54d09d25f5f44885d6b3e0a6d6643a8c070191580318d20c42/lxml-6.1.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b477912f42c5c33405a10c759d22f80cf5af043ae02d95b9d8e5e5bc555739ed", size = 5093324, upload-time = "2026-09-02T14:48:25.132Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/7e/8f340ddcd43790332fb0de8a26628d571a492da3300cd191821698407c96/lxml-6.1.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cffe18571ccc51d742cd08cbb3f8b756de9311d18c7ea98f5d92f37b8fb60c2", size = 4998850, upload-time = "2026-09-02T14:48:27.394Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/c1/9c5bb572f1f09ec9e4322bd4a4e9f4ad48347fc56ef94cf4df58a5279dc8/lxml-6.1.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75cc6569e86be5785b6188ef1642670c6adbc984e81ec35e224842ecd9eefcc8", size = 5626813, upload-time = "2026-09-02T14:48:29.61Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/7d/8bf1fd8bae8247743968bb76d027a1ac5bd2c4b44495fba6a71b30d10706/lxml-6.1.3-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d85dfab42dd672f87a7f76e9de7172962aee69fa12044f0d6e1a23cbd53fb80e", size = 5232385, upload-time = "2026-09-02T14:48:31.969Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/2e/6cef69ed81cb7df0d03b0dd09d08e6e2cf5061a743ff6f42f0b741548e9b/lxml-6.1.3-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:42632b4024ab24a6b488f559ac851312509888b6b80ae2aa11cf29a646a0d245", size = 5347088, upload-time = "2026-09-02T14:48:34.13Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/e1/8e5fd8ddc8c7d685badb0f2db149e3c9da84eefc2827c01c658df2c4e3cb/lxml-6.1.3-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:febd35ef45f603c2d74b74655efdbf45e14f55fc0aef4ac82b663ca829b283e0", size = 4707227, upload-time = "2026-09-02T14:48:36.62Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/7e/00041382a11be40a88bf405ebff11c8efabd3de79f2691e1638b1c47a8a0/lxml-6.1.3-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a43b3bdf11e477dc7770609d3477316f974354dfc8425d596f64f471cc8daf6e", size = 5240208, upload-time = "2026-09-02T14:48:38.893Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/fe/316538b5cff0936fa63d45d421c655730fcbb5a28dcac728c175083002bc/lxml-6.1.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d582042c69857c364e8153de6e18e0da9b7b515a6a8113caf69a6ec8e0520f2", size = 5050271, upload-time = "2026-09-02T14:48:41.213Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/91/455bcccb3ac725373007344d351151810cd19762d1673b64b811f4359a42/lxml-6.1.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8e49a646acfab83c68974f4aa1d0a2acca9e88d7d627ae0fc13201b14b76d310", size = 4780433, upload-time = "2026-09-02T14:48:43.779Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/f6/580440e2f52cf00bba5c5e1080bfa88cdfcde73be71a11d95170ddbb663f/lxml-6.1.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0dee106e9aa97fb00541b1ed7827070564d0549c3d3fba8920e6b20fd980f748", size = 5645928, upload-time = "2026-09-02T14:48:46.187Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/dc/d123c1f244306543d545f62443f794959e4f1ea709fe100f8740d514e74a/lxml-6.1.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:dd5e90f34cffcfed97f36cf066325773d2b6021c60c29942e53a18b028501b1d", size = 5231184, upload-time = "2026-09-02T14:48:48.691Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/3c/fe55b2bd5c6113c906511cd88f6a470195c5fbff1124f19970ab706c3477/lxml-6.1.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d9b3e7d71bf6acff341233417abbdface29c647e3113892d9aaedc02eb4aa2bc", size = 5255814, upload-time = "2026-09-02T14:48:50.948Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/a7/485df55acf55dc35e4ca89d2f48f03889e5a3241826b18b85102b32ce9d8/lxml-6.1.3-cp313-cp313-win32.whl", hash = "sha256:160fcf381f76c3aeac28a756bec44f48942a8f7245a87aa28e3a523b4d90cd87", size = 3602214, upload-time = "2026-09-02T14:48:53.236Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/28/e46a7702bd95e9043291f7c3539b6184cba66f96cea9936f20939b284eeb/lxml-6.1.3-cp313-cp313-win_amd64.whl", hash = "sha256:e477aca0bc0d19f3b4ae9e4f2a1cfd687c31bf772d78734910658186b40b2477", size = 4004091, upload-time = "2026-09-02T14:48:55.699Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/1d/154c78e20479a43916e63f19cb720d83f44f024b03228be44c92d9a97b24/lxml-6.1.3-cp313-cp313-win_arm64.whl", hash = "sha256:b1cc980905221a5d8b3c476330730b3adb40ff80add71ffbdb6215ba055656f1", size = 3665468, upload-time = "2026-09-02T14:48:57.703Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/15/fc75a70b0af6021d0ea16811f1fc71cc42cd06ce90fe10f007a69b2eed84/lxml-6.1.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2bec13085dc8ef48a3fe62f7dfcacfeda2c785cdf19cc8eeda2bb9ed081da165", size = 8609725, upload-time = "2026-09-02T14:49:00.156Z" },
+ { url = "https://files.pythonhosted.org/packages/84/ef/398fcf9018f881ec9aeaafae1ddd6586dfb13314a35d35e899de373dcae0/lxml-6.1.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4f4db7c7e954d289d71878938348b3d91b904a3e8210a11939359fb758a58e7d", size = 4639629, upload-time = "2026-09-02T14:49:02.81Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/2d/49b6a6ad7ce8f64b07b9fe852ff0c6d3fcbb26db61bee4f63d4120180a1c/lxml-6.1.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2cae5d5c90a62d9139c512a0cb1aad1d182b022b5740daea2617eb5bf7fc658e", size = 4965074, upload-time = "2026-09-02T14:49:05.133Z" },
+ { url = "https://files.pythonhosted.org/packages/66/bc/6230cf80e4331c33383b0b6b73dc31a393dd76edd4cb73d761de5123034d/lxml-6.1.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c6c0c13128a32eb04a51357e56a094e13aa8e6d3d1884de2e9ae923f6915e1a8", size = 5099355, upload-time = "2026-09-02T14:49:07.343Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/cf/d1143d9b7717e07a82f158a1fc9ce6e581fdad1226734950af869e3ffde4/lxml-6.1.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2221e88679d1351e9a40aaee54bc65679b9795bbd0160bc3d5e36b163344eb75", size = 5036795, upload-time = "2026-09-02T14:49:09.65Z" },
+ { url = "https://files.pythonhosted.org/packages/31/6f/194bb00ffb89712c30f5a7e1b8e685590e140fad6c8261fec172c09a3dc0/lxml-6.1.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cfb398886a7eb4c719161c3efcff2a1248febc53a4d8e5072d2d8a87fed84ac9", size = 5658740, upload-time = "2026-09-02T14:49:11.9Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/44/27e3cee3dcdb3b7bc09727b642bdbfcd098490ea77df04611db9060d7722/lxml-6.1.3-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7eb78ba28b187e1e9203a55c60fcf70df2d22cb205fe6d51b9383d6097419f0", size = 5245991, upload-time = "2026-09-02T14:49:14.154Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/e9/8312560579fc980bbd2233a8a673cc46f7d613d3633f2bf08a21e8f4ad13/lxml-6.1.3-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:ea6b1e9105b4b24a34c722432d9fb578f9ed83af21fa1abda639011e0f22bbb6", size = 5354136, upload-time = "2026-09-02T14:49:16.459Z" },
+ { url = "https://files.pythonhosted.org/packages/74/d8/eda60f4f73a9c780b5d6e1175484f66e6c81a2c93346e2906a1fec9c7a02/lxml-6.1.3-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:e8b17e23df3e827a69d25af70990ca2420e92668aaffaeeb3cd2351d7916a023", size = 4704379, upload-time = "2026-09-02T14:49:19.032Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/c8/c9cc60057be78ac34bd2b842e45e6e88edbfe5e532e82c3b82381b7aab49/lxml-6.1.3-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b7c37339d7e75cab9a123a04248e243cefefb302ad6db566ea0c77cbcde421e", size = 5258676, upload-time = "2026-09-02T14:49:21.306Z" },
+ { url = "https://files.pythonhosted.org/packages/41/7b/66894008fee8d1785b8db129747ae963fd427b68f456918df7f2f24a8b98/lxml-6.1.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:83e3a51e7933db700a0da0db31849db3a24022d9970da9bb73001e1d0326fd92", size = 5090069, upload-time = "2026-09-02T14:49:23.562Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/31/c1b60404859f4c3cd1f41f29c65a24e25cea78fde822d9574a21f66810be/lxml-6.1.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9bde9ae026a55b9a192078dfa6e27dd0ca4a050171ab6272e92f97b757dfdf48", size = 4741958, upload-time = "2026-09-02T14:49:26.037Z" },
+ { url = "https://files.pythonhosted.org/packages/23/b8/6285f0cf546f14da2554cabdeaf7c2c2ff3190c74807f0de2e8810a786f9/lxml-6.1.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1a635e837b50a1819bebfedaac5916498ea024120969da8790500148fb0a894d", size = 5683245, upload-time = "2026-09-02T14:49:28.438Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/f6/2168cab44336dcb15fed0f0b78577225b83297cdf0dee349c95420c3dcb0/lxml-6.1.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d0c5c362bc94f1929dc7e96e715bbe7bd17037f802e6d8f0d1545df9133c0559", size = 5246087, upload-time = "2026-09-02T14:49:30.955Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/89/32f5de69a0a31f30e6164981851f87b37ecb2c4ee838e504b88d49d4818e/lxml-6.1.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c59e4265608da6a041f54646ecc0c9ecdbb19aaf14c4c684bb6c2114998cc415", size = 5269352, upload-time = "2026-09-02T14:49:33.502Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/a1/741d952ed3a7ef7a50055c6415aec3f067015e97f72f4389ce77b09657ba/lxml-6.1.3-cp314-cp314-win32.whl", hash = "sha256:2e62c569ec7531b679b184cbfe335c501c1d13c4b363560013019962eb630e6d", size = 3662783, upload-time = "2026-09-02T14:50:23.751Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/bc/5811cc73cac05e324e05ba9b0924e1a163a317a167ede8a9c748b11db30a/lxml-6.1.3-cp314-cp314-win_amd64.whl", hash = "sha256:66299564c046bc7e0cc5de5106601eae907e9fa5904cd68a323380a8502f7861", size = 4073951, upload-time = "2026-09-02T14:50:26.348Z" },
+ { url = "https://files.pythonhosted.org/packages/92/18/3768c8b01ac3a9bed1914715e6011711b00e2a11628ffa6f7fa37f8e0269/lxml-6.1.3-cp314-cp314-win_arm64.whl", hash = "sha256:ebd054ad1737a68fb7c5c073d405cef2b88bb824e294de3b4a4e995b47f0e376", size = 3749279, upload-time = "2026-09-02T14:50:28.749Z" },
+ { url = "https://files.pythonhosted.org/packages/72/38/84684784738d9451db2b330de2483f496690c3a5c642071df24135739b37/lxml-6.1.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:5a143e6207579de8baeded4eaac9134413200359f1969d636f0bfb98ee8c3c8f", size = 8860296, upload-time = "2026-09-02T14:49:36.346Z" },
+ { url = "https://files.pythonhosted.org/packages/24/b7/fc4c50bb1b38e864010ea396046cabe85129bf9e65b11edcfbc37d356241/lxml-6.1.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a1cec0f99b9b914d39176347a93b7610dc09324491aee1cbc57cd291a41a1d55", size = 4755190, upload-time = "2026-09-02T14:49:39.872Z" },
+ { url = "https://files.pythonhosted.org/packages/94/e2/ee9aa6ed2b666b2db1f6f7fd48964ff9da39ebe827ef5eac0ab881f639d9/lxml-6.1.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f6b9d2aad499c769ee8287609ab0e6de99d8bcea99c6e6c2e64945259fd52fb2", size = 4979517, upload-time = "2026-09-02T14:49:42.153Z" },
+ { url = "https://files.pythonhosted.org/packages/29/e3/e7763d1661b283ddd4fa36f91b9a497db6b8d2aff55028b16c7f642e0755/lxml-6.1.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a23fefdb345b2d4d0ff2860571b5ff9a89a28b6a120f720e8fb0324d346626", size = 5115270, upload-time = "2026-09-02T14:49:44.493Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/cd/22205d5b4d177e3f4156f780412426ee7c7f8107809f119f0dcc40fa51e3/lxml-6.1.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:545ccc14fb05485f48b4439ec35beb16d5b5280eb6c81c658bd4707a2a119414", size = 5032449, upload-time = "2026-09-02T14:49:46.841Z" },
+ { url = "https://files.pythonhosted.org/packages/da/43/06a4626c3bb79ef8c501b674afab8100d64e798665bb2a97d1c960636a49/lxml-6.1.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:93476b6514b373fc6ca67d26c442784f7807c86f00635bfe79f935c3eab2af17", size = 5603325, upload-time = "2026-09-02T14:49:49.664Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/9c/733682a0c2de9f5779ba207bbb3f3f6be8c6bda863fc01739b186b38783a/lxml-6.1.3-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8db38ff3fb7aee7d6a82ae4da2eef1178656fe1216841fbd24870062a9d60473", size = 5229023, upload-time = "2026-09-02T14:49:52.447Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/8a/e69cdaca3fd33a647942925664f01b20908d41a6968c182305be9c38fb11/lxml-6.1.3-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:25f4118c438f96bb466e83108506d03d5c31b1bd2387e83e5b070bda6ded9c37", size = 5317811, upload-time = "2026-09-02T14:49:55.25Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/b2/0c397588174403c2ab68fc464abf97e03e7324f9c6cb6a99023104707195/lxml-6.1.3-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:1beb0f9909b26cee938df9ba56b15252a84429b1fc30ce6fca161390b9789a70", size = 4646516, upload-time = "2026-09-02T14:49:57.761Z" },
+ { url = "https://files.pythonhosted.org/packages/56/7e/cfea25afafbe49db8b225764f7f74bb37c2a7f5e717d917d3d4a5e098ed4/lxml-6.1.3-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3a27ac6c780c8b8a1cd231b58407634cafc1c4cc28cd6c7141362df0f36351e7", size = 5240626, upload-time = "2026-09-02T14:50:00.279Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/75/7a587771bb52ebb0e2c57b6dbe9fd96a70fbb54d72ddd97d54c5f8ec18d5/lxml-6.1.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a1932d7ce78a561367512c594fe66eac2b2ec9b9264cfd9b5f950622f4a116e2", size = 5086619, upload-time = "2026-09-02T14:50:03.245Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/01/94c0ebe6d831861542d251e038052e52bf6d33f1d18f1cfffdc82851065a/lxml-6.1.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:7d0f5976aa2701996f759b30172925829867547bb073af0ae67d1307a0f0262c", size = 4758828, upload-time = "2026-09-02T14:50:05.873Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/f1/938d67bd0e5b1fdfa52be28aefdffbad57e1f6b8e921c2aab88542c75f40/lxml-6.1.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:c5e7ce578aa8a80910a72a8ca0bbea3baae10100827249001999726a788456d8", size = 5627083, upload-time = "2026-09-02T14:50:08.555Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/65/4e51522f6c214650db0abb7b16ccd11b1238b8a05a8d59aa4ebed59c9f67/lxml-6.1.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d97c5227621af74b111882a290b10f371780a38eef9d9e730408fba2259b52fb", size = 5235170, upload-time = "2026-09-02T14:50:11.255Z" },
+ { url = "https://files.pythonhosted.org/packages/92/c2/e73d19365665f6b16ef84df21199befc3b06e4c539046ad2d9595f6fb9ea/lxml-6.1.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:da707f14ea3c35ee463d50acd596d6488e4b2b4ae7cf77a5bf93f55c023d63e8", size = 5252273, upload-time = "2026-09-02T14:50:13.782Z" },
+ { url = "https://files.pythonhosted.org/packages/48/a9/7f386c84c9fe2854e1ca6e231c285e1c8f392971ac353c6865e6ec49faff/lxml-6.1.3-cp314-cp314t-win32.whl", hash = "sha256:9efe56a68179f3adc4de41861c9358931db03837c48dd5e1c78077b84dd07f3a", size = 3902712, upload-time = "2026-09-02T14:50:16.171Z" },
+ { url = "https://files.pythonhosted.org/packages/82/a6/8a3eb793f7900ef01c7f99e6f5fcbcfbdff35251cfaef66b32a4c16352d6/lxml-6.1.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c9389b3784b56c58d933b5e0aecdf28f901b073ff385358d8a7d40907f6e14b2", size = 4400979, upload-time = "2026-09-02T14:50:18.621Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/c4/3807bea283b4fe9e9d9f5dde46a73df91178472b335d2778e10b2a37aa22/lxml-6.1.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32a409be3190b088f960ac92bfedfbef2f86c49ff940765e1548177592d20026", size = 3823401, upload-time = "2026-09-02T14:50:21.119Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/8e/4614fcd65496054cfb7172662f3576a59200278739506433b8c241ea422a/lxml-6.1.3-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:6ea2f13dce778ca072ccee598bca46a092ce192e8fd907b6c1f0e52c800529a0", size = 8609378, upload-time = "2026-09-02T14:50:31.772Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/51/2cdce3c65fa99a6195dd8fbd512d33407c1000ad99f63e0a285b63d7a8eb/lxml-6.1.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:c581b1d68b3845fb86c6b2983e755b29bf001461c59fa411d2c26a911b6559a9", size = 4640022, upload-time = "2026-09-02T14:50:34.41Z" },
+ { url = "https://files.pythonhosted.org/packages/52/09/0b30084e9eb1c546a4be3d9c56df70058d116b1a320400a59b0f7da87bf0/lxml-6.1.3-cp315-cp315-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e01125896585139453cab8cb235893644d8815d7509520da95ae3ee8d1c1f79", size = 5037928, upload-time = "2026-09-02T14:50:37.007Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/0e/5c37275a3e361f6138dc06db748ea565c1fe8a5f4ee5e2ddd80047c81a89/lxml-6.1.3-cp315-cp315-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:290f66b97ede0e552e1cb44a0fd8a74f9753ee635b50830a0b122fb72788d015", size = 5661932, upload-time = "2026-09-02T14:50:39.777Z" },
+ { url = "https://files.pythonhosted.org/packages/70/c5/b71ffb289b15e2642e2a3cf6d468c44da39ea119061a99e5b05e3d10f217/lxml-6.1.3-cp315-cp315-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:73fc05988ed20809450474ba760a87c8ad4e455fc09783c02195e56ec634b41a", size = 5249209, upload-time = "2026-09-02T14:50:42.141Z" },
+ { url = "https://files.pythonhosted.org/packages/81/ea/9910da149a23932f9301652e57661cd9e42b0df18f12be21159b7255f92b/lxml-6.1.3-cp315-cp315-manylinux_2_31_armv7l.whl", hash = "sha256:dc3a44689eea43eab836e5c98a8ab015dc2419987d1ea6eafc7c590cdff86bed", size = 4704543, upload-time = "2026-09-02T14:50:44.634Z" },
+ { url = "https://files.pythonhosted.org/packages/76/07/9290329cd188c62e22021f79df04ee0cc33d9a93b0d38bd65ccd452ad9d0/lxml-6.1.3-cp315-cp315-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:209c3ccbfe35a04ac6d24f0611f9d1cbf8025d49991b14acd935236234d6c156", size = 5261298, upload-time = "2026-09-02T14:50:47.301Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/0c/aba78bd3401cd99b73a0aed8e2b9b43e14be94fab3603d4bbc8a62365f2a/lxml-6.1.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2f5b2a2b9811b853b39bfa41367c6d78747b8e3e80e07fc5a24aae295c1a4d7d", size = 5090453, upload-time = "2026-09-02T14:50:49.952Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/dc/fa4426c3355aa0216cbeb3911495b5f65a26e0df85859a89928fe28f0396/lxml-6.1.3-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:6a406d0b3cb207b0fa460ed4dc93e866f44f105da0169361cb18ff998a44c7f0", size = 4744709, upload-time = "2026-09-02T14:50:52.394Z" },
+ { url = "https://files.pythonhosted.org/packages/be/2b/224fe7918658ab7c532ac2412f3c1eb28f71e6364fb07566262d0cc6a7b6/lxml-6.1.3-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:53258656846f5c48996b882fb4b135885e088a3ad3d96b4bc0530f95124d1f69", size = 5685802, upload-time = "2026-09-02T14:50:55.043Z" },
+ { url = "https://files.pythonhosted.org/packages/21/44/7d480819b9adcae5f84dd8ac529132c6b7a578544398225cd20321adcd91/lxml-6.1.3-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:aa633613ff907ea91b9b0489a1f0da1b8725d8c6ccec6b77e8a1c9c235044bb0", size = 5249019, upload-time = "2026-09-02T14:50:57.985Z" },
+ { url = "https://files.pythonhosted.org/packages/72/83/385a267ea1b6b283f2249dd827ef360a295e9db14e13ef4665a120c60d64/lxml-6.1.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:90f709b9accab6b2e4d14f5c8718203877a0486bcb3afd74d8b539ecd1e961d4", size = 5271886, upload-time = "2026-09-02T14:51:01.667Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/0d/f967b0eb172ae876855a402d6d9b11fa86e3e0c89ca9bbfeadf7ffbfa719/lxml-6.1.3-cp315-cp315-win32.whl", hash = "sha256:b4fc6b03b9d9d90557274f571ab30e7fbbfc527955536935d96f98b6817a86e4", size = 3662894, upload-time = "2026-09-02T14:51:45.173Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/48/d8a8c4160a29e663109ad520bac2deb37fcd014756d024561e8bc3e611ec/lxml-6.1.3-cp315-cp315-win_amd64.whl", hash = "sha256:33cadd956b667997e4de1635fce9541f2e8ede2038fcde8cf55aa14d571d1bad", size = 4074626, upload-time = "2026-09-02T14:51:47.77Z" },
+ { url = "https://files.pythonhosted.org/packages/25/20/3e1395d34d19f9254625d0b567b81cf70d37d3417be074f4d63b94a2be3c/lxml-6.1.3-cp315-cp315-win_arm64.whl", hash = "sha256:8a330c0ee5fa318c7b5cbbaad882baeca3f570357e7eb25ab34bf31008150758", size = 3749495, upload-time = "2026-09-02T14:51:50.663Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/c6/7465ffd9c43883526a382df6fa4846c9d8d419214f7effbf65270e795471/lxml-6.1.3-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:0bf5a3e397df2ec4258eb5eea4c1ac6cf013ca1abd04a176903bff20a70021fe", size = 8857677, upload-time = "2026-09-02T14:51:05.109Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/eb/1f3a917e299df43c8162c3e6f64fc2cea3bcf277910f35bff5b8e5d39901/lxml-6.1.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:13d22c0d57355366b393936acf6b98a5e0edeadddd3fccbc6a846c50a76b8741", size = 4754522, upload-time = "2026-09-02T14:51:08.137Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/f9/f81b4bdb6efb7a596be29603d8758154d00a5f545db9f3cef9d9041c8f64/lxml-6.1.3-cp315-cp315t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad7617727a96d189bd6f979d0fadf765198c7934e85f4edaba9bf3ad919a300", size = 5033744, upload-time = "2026-09-02T14:51:10.633Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/0f/26d9bfaacb319c86e0eca8a1a0bf1130d36a7afbd318883e23caea63763d/lxml-6.1.3-cp315-cp315t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cae82b5ca24b0c2beedb269f6e2a96f466acd926879ab00ae19f1a65cbf9ffb0", size = 5615269, upload-time = "2026-09-02T14:51:13.357Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/90/73675f3f4141350ed65d6fec533b107d4e802c5caa340cf111771edd86e0/lxml-6.1.3-cp315-cp315t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:69cafd61aea04ebb3502c93c2aaa568b12931ca0802231e0b5de76bf8b6e74bd", size = 5236280, upload-time = "2026-09-02T14:51:16.051Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/be/ed260767e7977de463a0f91f3f4fffcab85c0a2a024a21ffe1fa442c2c79/lxml-6.1.3-cp315-cp315t-manylinux_2_31_armv7l.whl", hash = "sha256:dc205732d593118cf701d986f40e9de7801bb2e371cb189ddbda9b7348f4d97e", size = 4650718, upload-time = "2026-09-02T14:51:19.102Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/fd/e9839d03b1e767f2725cf7d7d81b80d5f3f9fdc10ad8827e2479311b046e/lxml-6.1.3-cp315-cp315t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88e719b9437f148f7e1465df845c758dd1598618cbea3a2fd1e61a715542f2b2", size = 5243376, upload-time = "2026-09-02T14:51:21.606Z" },
+ { url = "https://files.pythonhosted.org/packages/34/a5/4606e347e2788c301f677004aa83e28d24da9fe663a24380122af57be6fc/lxml-6.1.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:40983eabefd13da003e68170928c7acc011f0d095eefce5871a3c71c9385fb9a", size = 5092340, upload-time = "2026-09-02T14:51:24.21Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/99/3314a8661cdf30f493c55a87db283961dfaae08451976a2ca418958e1804/lxml-6.1.3-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:fad67b12ffe0f71e02b4932b04883cbc76a9072bbd30731409d3523cf058b011", size = 4758768, upload-time = "2026-09-02T14:51:26.813Z" },
+ { url = "https://files.pythonhosted.org/packages/30/58/3bdc577f78ea8b7d72d39a84506f7001d5b28728f43e5b84891e3b7d9a4a/lxml-6.1.3-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6cd11e7550d89e551a87dcec30f04b1fca32e86b68708aa01a4daa455d8605e5", size = 5649546, upload-time = "2026-09-02T14:51:29.453Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/e4/652633de1a2395949ebb7a8fc7d089aba12a2b45f0fefbc9d29e3e3ab3cf/lxml-6.1.3-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:ca0ec532ad2f5ba1e5ec120ac157769c57f01855b3d8bf37213f5d88abd9ba0a", size = 5234874, upload-time = "2026-09-02T14:51:32.262Z" },
+ { url = "https://files.pythonhosted.org/packages/65/a6/c4581d171de30449304b4859bbd3607e9b40da13c0f88b68e6097c8d785e/lxml-6.1.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:e99e09ab7741f1281e2677f4c0058c7f5267d182530b09c87e4f6aa26adf3887", size = 5260043, upload-time = "2026-09-02T14:51:34.841Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/d7/ed6ee6186a89e69ca4ea9658b2a278f46a5efe8b5d4db56c7197f18653fe/lxml-6.1.3-cp315-cp315t-win32.whl", hash = "sha256:ace1d2c83b2bd24db5940600541140e87a325e119cb32d5fa9ad720d7e76648e", size = 3901093, upload-time = "2026-09-02T14:51:37.234Z" },
+ { url = "https://files.pythonhosted.org/packages/67/9d/11d10257a4a048d04195d638bb61f0246ce2448eb05f682bcbab25a257a8/lxml-6.1.3-cp315-cp315t-win_amd64.whl", hash = "sha256:b49638355ea3bebba70da783ccbc630fd72afa16bc46c54474bfa1f9a915bbc6", size = 4395446, upload-time = "2026-09-02T14:51:39.884Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/b7/44edd7de434181c582892e68d1ffe6775ca403ce14aea07cb5a218a936cf/lxml-6.1.3-cp315-cp315t-win_arm64.whl", hash = "sha256:5a721a98c649855963811b59b55755b30566e7f7fc40bdc9803d66dee9f811cf", size = 3822836, upload-time = "2026-09-02T14:51:42.471Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/c1/2433176de263cc3f51fd2c303f993d5bb7f1da3139a0f7d168116c0bfa7a/lxml-6.1.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:d2765c18ce303149ee804b1f3dad11232726dd0a702d73a15cf19179ac8cc962", size = 3942969, upload-time = "2026-09-02T14:46:36.55Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/71/de7759096f480180fd9e43ff7c017860e2d2a9a43741ab093cbdf1820f07/lxml-6.1.3-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d5a748d12dd9b535e0a130f60dae9ddf0adafbabe61e7864f55c7436c84547a", size = 4213008, upload-time = "2026-09-02T14:46:38.784Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/9b/c2d09af47a34fa6c0c27473083812b449a411680bd04bbe609cde291ddc8/lxml-6.1.3-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:41096ec0740a58dad03d3ae0c7486d306d20becefb13ceb1649835ab3eb64167", size = 4322012, upload-time = "2026-09-02T14:46:41.031Z" },
+ { url = "https://files.pythonhosted.org/packages/68/f3/bf56fee0403ebd995be8e78ec9aca566016487d1b3cbf755ebea8ccffbdb/lxml-6.1.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:415e3a115c0d510e329020012834d1c0aa1c581ee53a218603e38abbc1dea70a", size = 4257402, upload-time = "2026-09-02T14:46:43.134Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/1d/6da9cc086a20d9dd6bcbf7c5d9575f0331cca9a05e67dab02d15e828170b/lxml-6.1.3-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20428910dae17a1a93152a3ff2c0441d2f4932992c0797d65651dd0561f1792f", size = 4410889, upload-time = "2026-09-02T14:46:46.975Z" },
+ { url = "https://files.pythonhosted.org/packages/03/5c/91fe48856f9f8089be3096fa4dbe4b3fb5526f3bf3e852ea9497f399cb9f/lxml-6.1.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bc8dd3d9c93e70c3df974a201ac2958b6d77b465d813c51d1f15fa8e645763ae", size = 3511258, upload-time = "2026-09-02T14:46:49.046Z" },
+]
+
+[[package]]
+name = "networkx"
+version = "3.6.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" },
+]
+
+[[package]]
+name = "pyyaml"
+version = "6.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
+ { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
+ { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
+ { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
+ { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
+ { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
+ { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
+ { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
+ { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
+ { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
+ { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
+ { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
+ { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
+ { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
+ { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
+ { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
+ { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
+ { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
+ { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
+ { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
+ { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
+ { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
+ { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
+]
+
+[[package]]
+name = "tree-sitter"
+version = "0.26.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f7/03/5600b84aff2e6c4fe80cfebb4063fe2f50299521befe5f6092ab8c082f4a/tree_sitter-0.26.0.tar.gz", hash = "sha256:b40c219edccc4564530c96f8f1556f6202b37cda964d1cbd7bd2b7e68b40a245", size = 191423, upload-time = "2026-06-30T12:14:27.933Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/41/18/78aae7e4b5a36daaebb0276e4b07d084d45298758000787838e89329e11f/tree_sitter-0.26.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1d6fe0e8fb4df77b5ee816228e2c4475a63d8cc1d4d3a7ffd7097b2b87fc3e95", size = 148679, upload-time = "2026-06-30T12:13:52.27Z" },
+ { url = "https://files.pythonhosted.org/packages/24/e4/b371b9553b0e47d130fc2073e56cab94fecc868be04666bf5bbd1fcd1cc9/tree_sitter-0.26.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:514a9bf8993e5210e7970736aaf6020d1759b670e195ef17b1c48f586aa30736", size = 140759, upload-time = "2026-06-30T12:13:53.221Z" },
+ { url = "https://files.pythonhosted.org/packages/22/7d/266fb0f2c41e6fb00b0f40e7a3338cdf99651e6a6511ca72bc78fc697636/tree_sitter-0.26.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10f0d4eb94aa7242dcb7f554bcd24dd7ba1c114f00d58759ba08c7a46c8ec51a", size = 637206, upload-time = "2026-06-30T12:13:54.334Z" },
+ { url = "https://files.pythonhosted.org/packages/40/9f/47cf22febb47132d5b3a507a27bb99ef89fe5c8ec420a13c6daa9b64f782/tree_sitter-0.26.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:335294ce0504fcefde5245dff596778ffaf820205b98ae0b549c72e48855f1d8", size = 664758, upload-time = "2026-06-30T12:13:55.42Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/4d/8d144ca3beb46a62a5102b6deac76bb0da55235c2c7840faf3b12f2e9d97/tree_sitter-0.26.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9997ba61368c48ed54e715676afadf703947a1542464e39d047764fb3624b01", size = 647438, upload-time = "2026-06-30T12:13:56.523Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/ed/ed1d6e78520c4fb64ed52fec3f2947bf8c1fbad7bc24e282c56193c9ba42/tree_sitter-0.26.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c56581ad256c4195a21bfe449fed5d44a02fe83a4a7d6e70e6ec302c881191c7", size = 661944, upload-time = "2026-06-30T12:13:57.82Z" },
+ { url = "https://files.pythonhosted.org/packages/10/83/45f5bd43db1b8248d2fd08ef6cbe43e2725c539e09a2cfb8bc2818646788/tree_sitter-0.26.0-cp311-cp311-win_amd64.whl", hash = "sha256:0f8793fd18ad7eec276ed4b51c097b4bf2002b357259b66b0d75db1f3f41c754", size = 129496, upload-time = "2026-06-30T12:13:59.216Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/8d/be68e6c04563eb54145424cc83fe0aa8b0ba6c90d8989cf8a032671b5f16/tree_sitter-0.26.0-cp311-cp311-win_arm64.whl", hash = "sha256:dea4b4e27d49e9ec5b785d4f994da000e6726882fcc6ad05ec98478500c71aef", size = 116484, upload-time = "2026-06-30T12:14:00.147Z" },
+ { url = "https://files.pythonhosted.org/packages/87/ca/565702c44815393e3a973552ad546db4e5ca081ca8698640b4e93d809f51/tree_sitter-0.26.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6cb2bd20efb2544c19ac54486ab7cb8ec7b36f913bbe1ce95df84acb96743d9c", size = 148934, upload-time = "2026-06-30T12:14:01.188Z" },
+ { url = "https://files.pythonhosted.org/packages/54/6f/8bb61957f16ec1b1d92410a006cdc84a952b6352a7313b2ad299f2d21484/tree_sitter-0.26.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:918d89529786873f0982a0f59c2a303cd065fbfd1b903d71a8e4e1584f67b42e", size = 140820, upload-time = "2026-06-30T12:14:02.087Z" },
+ { url = "https://files.pythonhosted.org/packages/78/0a/8a6f08559182643a814a4ab559948ae817b2851890fd9b995a4fff6541ce/tree_sitter-0.26.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:30a88be89ff1f2755297f81e8080d88b795dd98720c3f9fa2acf93873182cc95", size = 638844, upload-time = "2026-06-30T12:14:03.428Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/2f/6e6781b31677231366cb3cf27bc8269157f6d4b03c9032865a4f5f2bbe7e/tree_sitter-0.26.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a6b333b0282d8bb0af741f9b018bd2523d4eecb2686bf6717066a625fecfaa4", size = 667487, upload-time = "2026-06-30T12:14:04.669Z" },
+ { url = "https://files.pythonhosted.org/packages/02/0b/0483078c8567445557a7015b0e5b187f6d7d4fda73464df9c4bdea7f7f3c/tree_sitter-0.26.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3f3c44339dd34fe8eb2b8d5aa7610660499a795f70376b130bbee7a437337280", size = 647975, upload-time = "2026-06-30T12:14:05.797Z" },
+ { url = "https://files.pythonhosted.org/packages/27/68/da83ca72c984e96ab4eb3bee0db1a6ffb5de1c8c455f92bd9f420cde7f0e/tree_sitter-0.26.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:94550e13b6ae576969da40246f4c4abb206380b5375ad43f26dd9151d55438e3", size = 665018, upload-time = "2026-06-30T12:14:07.278Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/36/4d67927fd47b89af4a00f65f55a7370e28778cd50e972c2430487e3ecc27/tree_sitter-0.26.0-cp312-cp312-win_amd64.whl", hash = "sha256:ca89e361a276dbc934b28a43dd881199e25d34ff5493ee0ce45f3c52a6124a37", size = 129619, upload-time = "2026-06-30T12:14:08.373Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/72/cdefad523eb78710679c6da6a79e3d90f5afd32b1c6aa5a17bac7eef99f6/tree_sitter-0.26.0-cp312-cp312-win_arm64.whl", hash = "sha256:bc6cb01d5ee75c85424aa1f1c72a82d8f07fd52539a0f3c4a6ed3e8721079b84", size = 116545, upload-time = "2026-06-30T12:14:09.273Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/b0/465257cf8f972ad9f9812ec1cbaa8ec210ebebb601ade9a15881aa2436b4/tree_sitter-0.26.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ed0889dbed843ce45ede9f5169c0b2dea2222f12685844a03fadb81f12705867", size = 148893, upload-time = "2026-06-30T12:14:10.541Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/ec/19d093e854b45e807fecfdd26105c266f43aeecc39c4dc97992a7074ad5a/tree_sitter-0.26.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6189c6c340c7384357711e3d92645e96bfb79f7a502f86de1ebdb23eb43f7dab", size = 140829, upload-time = "2026-06-30T12:14:11.626Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/ee/87e74671ed63a837e7a1f17ab94aa3913871e033b27523d8e7b83d6f7ad0/tree_sitter-0.26.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ff2e0750b7daa722302838356d7b65e303829b7eb73c915df127ddba115e1d1", size = 639334, upload-time = "2026-06-30T12:14:12.836Z" },
+ { url = "https://files.pythonhosted.org/packages/66/e7/f7e04cd9dff6b6ac0adf23922796fbc76accd4cf4bcda50542748d485679/tree_sitter-0.26.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7075ef857ef86f327dbb72d1e2574dda78db5754b3a1fca6506acd7fe5d561a7", size = 668102, upload-time = "2026-06-30T12:14:14.035Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/90/0bfb16b7894fea728c774a89d5af421a9368a2f913bbd4e8dcab7caaecfb/tree_sitter-0.26.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:26c996c1edfee86e977bb3f5462e74fcec0d0b0db1e85a3c475875763caa03be", size = 648560, upload-time = "2026-06-30T12:14:15.302Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/e6/0fe05ba396e9623b0ae40ccf34171336b8701ec8d7bd0ee9f5224d638665/tree_sitter-0.26.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:00289bfe7978f3e0dc0ce69813a20fa9f44ea4c100b3ec62043e5eb74ccfc3a2", size = 665121, upload-time = "2026-06-30T12:14:16.403Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/d2/a944b1ca35bed6068dc84a9967aaf3049d8cc0b7a36179eea8787270a6ab/tree_sitter-0.26.0-cp313-cp313-win_amd64.whl", hash = "sha256:93e220cab7e6a823efeb2046c49171427de92ef71c7c681c01820d14d8d3721f", size = 129615, upload-time = "2026-06-30T12:14:17.463Z" },
+ { url = "https://files.pythonhosted.org/packages/09/ef/c7ca48293580d2249f36940c4eed5b4ddeb9ce75baf9a4ef30621987e0c7/tree_sitter-0.26.0-cp313-cp313-win_arm64.whl", hash = "sha256:b31a8195d2f224224c530ac814632d98c1dcc123d227442c07c736e86b70d564", size = 116525, upload-time = "2026-06-30T12:14:18.53Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/7a/4d84e6f6ae2c3e757490dd84de251712c31e293dfe31f28da1ec019cefa2/tree_sitter-0.26.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5a3c93a352b7e6f70f73e121bbfa2d0117ba7478bd51114ed35c91b0b78814fa", size = 148901, upload-time = "2026-06-30T12:14:19.452Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/d9/efe62ec65dc9d096e834d27b8c058127e2146e42ff3380b822a233f016a6/tree_sitter-0.26.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5fc2f41bf246ff2f70a9cc3690be35ec7580a4923151873d898c8bcb1a4503d3", size = 140805, upload-time = "2026-06-30T12:14:20.478Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/2c/c82326b7b97e3c485c18679883b16f89e5e913c639d3b219d3da70c9e67e/tree_sitter-0.26.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8ea92a255c91671a7ec4625aba3ab7bb5220c423630ffbf83c45d7312abe084", size = 640586, upload-time = "2026-06-30T12:14:21.527Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/7a/f56e7d8282859452611024c7cbc623bfba5b24b8cb9b8f8bc88c5219fe9a/tree_sitter-0.26.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f665510f0fcf4636fb9696f1f7853bed7a3bd764b7bb0cb8494e619c14ed5a0c", size = 668300, upload-time = "2026-06-30T12:14:22.728Z" },
+ { url = "https://files.pythonhosted.org/packages/91/51/240ee81b9d5e9ca0a6cb1528e8605ffa70ab58c89ce126631be96d3e4bae/tree_sitter-0.26.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:253df7ab82cc0a9d311cd65f06e9f99fb3eac55996ae9fc94da22f123a861b90", size = 649627, upload-time = "2026-06-30T12:14:23.819Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/54/760035cefedf9eb44f0f84c4ac22f1322e73155853e272576ee876336312/tree_sitter-0.26.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ff80d4833d330a73184a3ac5132abe93c575d2dea31975c6f15c0d21fef238aa", size = 664885, upload-time = "2026-06-30T12:14:25.064Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/1b/0b36fe2a984ecedc4ce6aefd5d56447a6626a8e9b595c4e48658510ce8f8/tree_sitter-0.26.0-cp314-cp314-win_amd64.whl", hash = "sha256:a4033fecc8f606c7f2e8b8014d0057b74668a7f0152763606f7bc25c5f9ec64c", size = 132688, upload-time = "2026-06-30T12:14:26.106Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/74/ebc041a13fbf40144afdb0d4b447e48e0b4012ca866c63de8b48f801f0c1/tree_sitter-0.26.0-cp314-cp314-win_arm64.whl", hash = "sha256:823251c4b6725a7c03ed497a339135ede7ae4bdde75bb8be7ef5e305aeb4ff52", size = 120287, upload-time = "2026-06-30T12:14:26.991Z" },
+]
+
+[[package]]
+name = "tree-sitter-c-sharp"
+version = "0.23.5"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9f/fb/7e2962bc1901daf264e7ce263b168e0139304a5f8f66c9b2baf20e550f87/tree_sitter_c_sharp-0.23.5.tar.gz", hash = "sha256:2635c7d5ec93e59f2e831b571bed99c4cc68a5d183a0994020aa769e1b990a71", size = 1147914, upload-time = "2026-04-14T16:11:22.441Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ec/c4/86d8d469400a856757a464a6ac01af97d8cdacbb595e62bdb98bf1e9db90/tree_sitter_c_sharp-0.23.5-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:61e1981cf21b09ee547b9c4c68e64fb4394325f8fc8d5f6d50d41471eba923ea", size = 333658, upload-time = "2026-04-14T16:11:11.288Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/13/593c8603f834eaf15082b81e079289fc9f062b4c0ab5b9489134084eec06/tree_sitter_c_sharp-0.23.5-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:a75994a11f6fed3f5b8c36ad6a00e5dc43205bd912c43af3a2a54fdf649664eb", size = 376296, upload-time = "2026-04-14T16:11:12.972Z" },
+ { url = "https://files.pythonhosted.org/packages/41/5a/a8855cbb5bbab28adb29c2c7f0e7be5a9f1d21450c13b3c3e613190d9b8c/tree_sitter_c_sharp-0.23.5-cp310-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:aa88a780204cd153c4c1ae2d59c654cee1402212fa0d069823d6d34301587438", size = 358333, upload-time = "2026-04-14T16:11:14.214Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/c8/e0f391e343f5424d0627e3b6886c77baeb1249a3f10986be00b0b64ecdab/tree_sitter_c_sharp-0.23.5-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ea38fb095d85d360dc5a0bec2fa605e496228876f798c9e089d5f0e72bcef46", size = 359448, upload-time = "2026-04-14T16:11:15.419Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/fc/10f807ac79f928241c5e0d827fdaf91e97dfba662fc7e07d7bd664140ec1/tree_sitter_c_sharp-0.23.5-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:05a9256415e7f24d4f133133794a9c224c60d19f677a04e2f6a94c25090b6d65", size = 358144, upload-time = "2026-04-14T16:11:17.087Z" },
+ { url = "https://files.pythonhosted.org/packages/de/2a/6c3e12ef0cf09138717fcc02e1de8b76a3928d1bed65c7e3c2bd3172bcef/tree_sitter_c_sharp-0.23.5-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8636dc70b5a373c35c1036ed5de98e801f2e4d105ae41e2e20b6804c36e3bf33", size = 357525, upload-time = "2026-04-14T16:11:18.214Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/e0/bd287b092d611df95a9149117fd27b5947ce75527113d6898a4b4e2c8858/tree_sitter_c_sharp-0.23.5-cp310-abi3-win_amd64.whl", hash = "sha256:41a28cfa3d9ea50f5629e44550a03188c8fbd5079803dfc03554b6fd594b33fa", size = 338756, upload-time = "2026-04-14T16:11:19.661Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/fb/114ff43fdd256d0befed32f77c1dadee9517867181c70794571f718ed05c/tree_sitter_c_sharp-0.23.5-cp310-abi3-win_arm64.whl", hash = "sha256:2de4ebf95ddc2e92cd3105c8a8e0e7ec646bc82f52bfaf2f3acec0fa2401ec09", size = 337260, upload-time = "2026-04-14T16:11:20.849Z" },
+]
+
+[[package]]
+name = "tree-sitter-python"
+version = "0.25.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b8/8b/c992ff0e768cb6768d5c96234579bf8842b3a633db641455d86dd30d5dac/tree_sitter_python-0.25.0.tar.gz", hash = "sha256:b13e090f725f5b9c86aa455a268553c65cadf325471ad5b65cd29cac8a1a68ac", size = 159845, upload-time = "2025-09-11T06:47:58.159Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cf/64/a4e503c78a4eb3ac46d8e72a29c1b1237fa85238d8e972b063e0751f5a94/tree_sitter_python-0.25.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:14a79a47ddef72f987d5a2c122d148a812169d7484ff5c75a3db9609d419f361", size = 73790, upload-time = "2025-09-11T06:47:47.652Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/1d/60d8c2a0cc63d6ec4ba4e99ce61b802d2e39ef9db799bdf2a8f932a6cd4b/tree_sitter_python-0.25.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:480c21dbd995b7fe44813e741d71fed10ba695e7caab627fb034e3828469d762", size = 76691, upload-time = "2025-09-11T06:47:49.038Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/cb/d9b0b67d037922d60cbe0359e0c86457c2da721bc714381a63e2c8e35eba/tree_sitter_python-0.25.0-cp310-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:86f118e5eecad616ecdb81d171a36dde9bef5a0b21ed71ea9c3e390813c3baf5", size = 108133, upload-time = "2025-09-11T06:47:50.499Z" },
+ { url = "https://files.pythonhosted.org/packages/40/bd/bf4787f57e6b2860f3f1c8c62f045b39fb32d6bac4b53d7a9e66de968440/tree_sitter_python-0.25.0-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be71650ca2b93b6e9649e5d65c6811aad87a7614c8c1003246b303f6b150f61b", size = 110603, upload-time = "2025-09-11T06:47:51.985Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/25/feff09f5c2f32484fbce15db8b49455c7572346ce61a699a41972dea7318/tree_sitter_python-0.25.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e6d5b5799628cc0f24691ab2a172a8e676f668fe90dc60468bee14084a35c16d", size = 108998, upload-time = "2025-09-11T06:47:53.046Z" },
+ { url = "https://files.pythonhosted.org/packages/75/69/4946da3d6c0df316ccb938316ce007fb565d08f89d02d854f2d308f0309f/tree_sitter_python-0.25.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:71959832fc5d9642e52c11f2f7d79ae520b461e63334927e93ca46cd61cd9683", size = 107268, upload-time = "2025-09-11T06:47:54.388Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/a2/996fc2dfa1076dc460d3e2f3c75974ea4b8f02f6bc925383aaae519920e8/tree_sitter_python-0.25.0-cp310-abi3-win_amd64.whl", hash = "sha256:9bcde33f18792de54ee579b00e1b4fe186b7926825444766f849bf7181793a76", size = 76073, upload-time = "2025-09-11T06:47:55.773Z" },
+ { url = "https://files.pythonhosted.org/packages/07/19/4b5569d9b1ebebb5907d11554a96ef3fa09364a30fcfabeff587495b512f/tree_sitter_python-0.25.0-cp310-abi3-win_arm64.whl", hash = "sha256:0fbf6a3774ad7e89ee891851204c2e2c47e12b63a5edbe2e9156997731c128bb", size = 74169, upload-time = "2025-09-11T06:47:56.747Z" },
+]
+
+[[package]]
+name = "tree-sitter-typescript"
+version = "0.23.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1e/fc/bb52958f7e399250aee093751e9373a6311cadbe76b6e0d109b853757f35/tree_sitter_typescript-0.23.2.tar.gz", hash = "sha256:7b167b5827c882261cb7a50dfa0fb567975f9b315e87ed87ad0a0a3aedb3834d", size = 773053, upload-time = "2024-11-11T02:36:11.396Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/28/95/4c00680866280e008e81dd621fd4d3f54aa3dad1b76b857a19da1b2cc426/tree_sitter_typescript-0.23.2-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:3cd752d70d8e5371fdac6a9a4df9d8924b63b6998d268586f7d374c9fba2a478", size = 286677, upload-time = "2024-11-11T02:35:58.839Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/2f/1f36fda564518d84593f2740d5905ac127d590baf5c5753cef2a88a89c15/tree_sitter_typescript-0.23.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:c7cc1b0ff5d91bac863b0e38b1578d5505e718156c9db577c8baea2557f66de8", size = 302008, upload-time = "2024-11-11T02:36:00.733Z" },
+ { url = "https://files.pythonhosted.org/packages/96/2d/975c2dad292aa9994f982eb0b69cc6fda0223e4b6c4ea714550477d8ec3a/tree_sitter_typescript-0.23.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b1eed5b0b3a8134e86126b00b743d667ec27c63fc9de1b7bb23168803879e31", size = 351987, upload-time = "2024-11-11T02:36:02.669Z" },
+ { url = "https://files.pythonhosted.org/packages/49/d1/a71c36da6e2b8a4ed5e2970819b86ef13ba77ac40d9e333cb17df6a2c5db/tree_sitter_typescript-0.23.2-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e96d36b85bcacdeb8ff5c2618d75593ef12ebaf1b4eace3477e2bdb2abb1752c", size = 344960, upload-time = "2024-11-11T02:36:04.443Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/cb/f57b149d7beed1a85b8266d0c60ebe4c46e79c9ba56bc17b898e17daf88e/tree_sitter_typescript-0.23.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8d4f0f9bcb61ad7b7509d49a1565ff2cc363863644a234e1e0fe10960e55aea0", size = 340245, upload-time = "2024-11-11T02:36:06.473Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/ab/dd84f0e2337296a5f09749f7b5483215d75c8fa9e33738522e5ed81f7254/tree_sitter_typescript-0.23.2-cp39-abi3-win_amd64.whl", hash = "sha256:3f730b66396bc3e11811e4465c41ee45d9e9edd6de355a58bbbc49fa770da8f9", size = 278015, upload-time = "2024-11-11T02:36:07.631Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/e4/81f9a935789233cf412a0ed5fe04c883841d2c8fb0b7e075958a35c65032/tree_sitter_typescript-0.23.2-cp39-abi3-win_arm64.whl", hash = "sha256:05db58f70b95ef0ea126db5560f3775692f609589ed6f8dd0af84b7f19f1cbb7", size = 274052, upload-time = "2024-11-11T02:36:09.514Z" },
+]