Skip to content

Commit 7e763ea

Browse files
authored
Merge pull request #68 from petercorke/docs/agents-llms-txt
docs: llms.txt, llms-full.txt and AGENTS.md
2 parents 8210675 + 0311897 commit 7e763ea

6 files changed

Lines changed: 235 additions & 7 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ jobs:
9999
sudo apt-get install -y graphviz
100100
- name: Build docs
101101
run: |
102-
make -C docs html
102+
make -C docs html llms
103103
touch docs/build/html/.nojekyll
104104
- name: Deploy docs to gh-pages
105105
uses: peaceiris/actions-gh-pages@v4

‎AGENTS.md‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# pgraph-python — Agent Instructions
22

3+
> **Audience:** agents working on pgraph's source code. If you're helping someone *use* pgraph, read https://petercorke.github.io/pgraph-python/llms.txt instead (or `llms-full.txt` for the complete documentation).
4+
35
Part of the RVC ecosystem. **Read [rvc-ecosystem/AGENTS.md](https://github.com/petercorke/rvc-ecosystem/blob/main/AGENTS.md) first** — it defines shared conventions: repo ownership, math invariants, dependency boundaries, git/PR workflow, code standards, tech-debt tracking. This file only adds what's specific to this repo.
46

57
| | |
@@ -8,10 +10,19 @@ Part of the RVC ecosystem. **Read [rvc-ecosystem/AGENTS.md](https://github.com/p
810
| Nickname | pgraph-python |
911
| Owner | Peter Corke (`petercorke`) |
1012
| Default branch | `main` |
11-
| Contribution model | Branch → PR; direct push to `main` at Peter's discretion |
13+
| Contribution model | Branch → PR; `main` is protected: CI must pass and the branch must be up to date (`gh pr update-branch`) |
1214

1315
## Notes specific to this repo
1416

15-
- Minimal graph/path-planning utility package — no internal ecosystem dependencies.
16-
- Used by `robotics-toolbox-python` (path/roadmap planning) and `machinevision-toolbox-python`.
17-
- Pure Python — builds with Hatch (`hatchling`) directly.
17+
- Minimal graph/path-planning package. Pure Python, builds with Hatch (`hatchling`); Python 3.9+. Depends on `numpy`, `matplotlib` and `spatialmath-python`; `ansitable` and `scipy` arrive through spatialmath. **Do not add scipy to `pyproject.toml`**: it is imported lazily, only inside `Graph.build_kdtree()`.
18+
- Used, unpinned (`"pgraph-python"`), by `robotics-toolbox-python` (`PRMPlanner`, `RRTPlanner`, `LatticePlanner`, `PoseGraph`, which subclass `UVertex`/`DVertex`) and `machinevision-toolbox-python` (`BundleAdjust`). A breaking change needs a deprecation cycle with a working fallback (`warnings.warn(..., DeprecationWarning, stacklevel=2)`); run their pgraph-using tests before a release.
19+
- **Layout.** `src/pgraph/PGraph.py` holds `Graph`, `UGraph`, `DGraph`, `Vertex`, `Edge` and `SE2Metric`; `exporters.py` the `export()` formats. There is one `Vertex` class: a vertex has no direction, the graph decides how an edge is recorded (`UGraph`/`DGraph` implement the hooks `_attach`, `_detach`, `_incoming` and `_endpoints_match`). `UVertex`/`DVertex` are deprecated aliases, by module `__getattr__`.
20+
- **Records that must agree.** A vertex has `_edgelist` (leaving, or incident if undirected) and `_inlist` (entering, directed only); the graph has `_vertexlist`, `_vertexdict` (names) and `_edges` (an insertion-ordered dict). `g.validate()` checks them all; use it in tests of anything that edits the structure.
21+
- **Two staleness flags, consumed independently.** `_connectivitychange` (components, reset by `_graphcolor`) and `_spatialchange` (the k-d tree). Set both wherever a vertex is added or removed; only connectivity cares about edges. Never share one flag.
22+
- **`Vertex.__setattr__` is hot.** It exists to tell the graph that `coord` was assigned. Anything run per vertex (`Vertex.__init__`, `add_vertex`, components) writes `vertex.__dict__` directly to skip it; keep doing that, and declare new attributes at class level so mypy knows them.
23+
- **Pickle and `copy` use a flat description** (`__getstate__`/`__setstate__`: vertices, edges by index, replayed through `_attach`); `subgraph` reuses it. Derived or transient state on a graph (the k-d tree) must be excluded there. A bare vertex or edge refuses to be pickled or copied.
24+
- **Tests** are `unittest` classes in `tests/`, run with `pytest`; write expected values by hand or from an oracle in the test, never from the code under test. Import names explicitly (a star import is a Codacy `F405`); route `pickle` through one helper marked `# nosec B301 nosemgrep`. Codecov gates patch coverage, so every changed line needs a test. `mypy src` is a CI check.
25+
- **Performance claims are measured.** `examples/benchmark.py` (`--md`, `--directed`) produces the README tables; regenerate them after a performance change and compare with `v0.6.5` (`git archive v0.6.5 src`), not with a development build.
26+
- **Docs** are Sphinx (`make -C docs html`; `-n` for nitpicky) with `sphinx-pyrunblock` `.. runblock:: pycon` examples that execute at build. `docs/llms_intro.md` is the hand-written part of `llms.txt`: update it when the API changes; `docs/llms_txt.py` builds `llms.txt` and `llms-full.txt` from it and the toctrees (`make html llms`), and CI publishes them with the site.
27+
- **Release.** Version is in `pyproject.toml`, history in `CHANGELOG.md`. Publishing is `.github/workflows/publish.yml` on a GitHub Release (trusted publishing, environment `pypi`): it refuses unless the tag equals the version and the commit is on `main`. Don't tag or upload by hand.
28+
- `examples/london.json` is OpenStreetMap data (© OpenStreetMap contributors, ODbL); keep the attribution with it.

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ Map data © OpenStreetMap contributors, available under the [Open Database Licen
9191

9292
## Using pgraph
9393

94-
Full details are given in the [online documentation](https://petercorke.github.io/pgraph-python).
94+
Full details are given in the [online documentation](https://petercorke.github.io/pgraph-python). For an AI assistant there is a short summary, [llms.txt](https://petercorke.github.io/pgraph-python/llms.txt), and the complete documentation as one file, [llms-full.txt](https://petercorke.github.io/pgraph-python/llms-full.txt).
9595

9696
### Properties and methods of the graph
9797

‎docs/Makefile‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@ BUILDDIR = build
1212
help:
1313
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
1414

15-
.PHONY: help Makefile
15+
# llms.txt and llms-full.txt for AI assistants, written into build/html; run after html
16+
llms:
17+
SPHINXBUILD="$(SPHINXBUILD)" python llms_txt.py
18+
19+
.PHONY: help Makefile llms
1620

1721
# Catch-all target: route all unknown targets to Sphinx using the new
1822
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).

‎docs/llms_intro.md‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
pgraph is a pure-Python package of graph classes: directed and undirected graphs of vertices connected by edges, with edge costs and path planning (breadth-first, uniform-cost and A*). Vertices can be *embedded*, given a coordinate in a space of any dimension; edge costs then come from a distance metric (L2, L1, SE2 or your own), and the graph can be queried spatially, plotted and exported. Self-loops and parallel edges are allowed. It requires Python 3.9 or later and depends on NumPy, Matplotlib and spatialmath-python. Install with `pip install pgraph-python`; import it as `pgraph`.
2+
3+
## Quick start
4+
5+
```python
6+
from pgraph import UGraph
7+
8+
g = UGraph(dim=2) # DGraph for directed; Graph(directed=flag) chooses
9+
a = g.add_vertex([0, 0], name="a") # returns the Vertex
10+
b = g.add_vertex([3, 4], name="b")
11+
c = g.add_vertex([6, 0], name="c")
12+
g.add_edge(a, b) # cost defaults to the metric distance, 5.0
13+
g.add_edge("b", "c", cost=7) # vertices or their names; or give the cost
14+
15+
path, length, _ = g.path_Astar("a", "c") # None if there is no path
16+
print([v.name for v in path], length) # ['a', 'b', 'c'] 12.0
17+
g.plot()
18+
g.highlight_path(path)
19+
```
20+
21+
## Key points
22+
23+
- **Classes.** `UGraph` (undirected) and `DGraph` (directed) share the base class `Graph`, which is concrete: `Graph(directed=True)` is a `DGraph`. There is one `Vertex` class for both and one `Edge` class. `UVertex` and `DVertex` are deprecated aliases of `Vertex`. Constructor arguments are keyword-only: `UGraph(dim=2, metric="L2", heuristic=None, vertex_class=None, edge_class=None, verbose=False)`; `UGraph(2)` is a `TypeError`.
24+
- **Vertices.** `g.add_vertex(coord=None, name=None)` returns the vertex. Default names are `"#0"`, `"#1"`, ..., names are unique, and `g["a"]` or `g[0]` finds a vertex. `for v in g` iterates in the order added; `g.n`, `g.ne` and `g.nc` are the numbers of vertices, edges and components. `g.remove_vertex(v)` and `g.remove_edge(e)` remove them, with their edges.
25+
- **Edges.** `g.add_edge(v1, v2, cost=None)`; with no `cost` it is the metric distance between the coordinates, and `None` if the vertices have none (planning then raises `ValueError`). `g.edges()` and `v.edges()` return new lists in the order added. `e.v1` and `e.v2` are read-only. `v.adjacent()` is the neighbours, `v.successors()` and `v.predecessors()` are the directed ones, `v.edgeto(w)` the cheapest edge to `w` and `v.edges_to(w)` all of them. A self-loop of an undirected vertex is in `v.edges()` twice and counts 2 in its degree.
26+
- **Directed graphs.** `degree` is in plus out (`in_degree`, `out_degree`); `nc` and `component` are weakly connected, ignoring direction.
27+
- **Planning.** `path_BFS(S, G)` returns `(path, length)` with the fewest edges; `path_UCS(S, G)` and `path_Astar(S, G)` return `(path, length, parents)` of least cost. `S` and `G` are vertices or names, and all three return `None` when there is no path, so check before unpacking.
28+
- **Metrics.** `"L2"` (default), `"L1"`, `"SE2"` for `(x, y, theta)` poses, `SE2Metric(weight)` to weight the angle, or a function of one difference vector. A* uses `heuristic` (default: the metric), which must be in the same units as the edge costs and must not exceed the true cost to go, or the path is not the shortest.
29+
- **Spatial queries** on an embedded graph: `closest(point)` returns `(vertex, distance)`, `near(point, radius)`, `within(box)` with `box` a 2 x n array of lower and upper corner, `coordinates()` the `(dim, n)` matrix of coordinates, and `subgraph(vertices)`, which takes vertices, names or a boolean mask and returns a new graph. `build_kdtree()` is an optional index (it needs scipy) that `closest` and `near` then use.
30+
- **Components and matrices.** `g.component(c)` is the vertices in component number `c`, `v.label` is the component of a vertex, `g.samecomponent(v1, v2)`. `adjacency()`, `incidence()`, `laplacian()`, `degree()` and `connectivity()` return dense NumPy arrays.
31+
- **Output.** `g.plot()` (arrowheads for a directed graph, arcs for parallel edges, circles for loops), `highlight_path(path)`, and `g.export(file)` for GraphViz dot, Mermaid, GraphML, ELK JSON or an image (images need the GraphViz executables).
32+
- **Subclassing.** `class Foo(Vertex)` and `UGraph(vertex_class=Foo, edge_class=Bar)` make the graph build them; a subclass `__init__` must pass `coord` and `name` (vertices) or `v1, v2, cost, data` (edges) to its parent.
33+
- **Copying.** `g.copy()` and `pickle` work for a whole graph; a vertex or edge cannot be copied or pickled on its own, and a graph whose metric is a `lambda` cannot be pickled.
34+
35+
## Common mistakes
36+
37+
- `UGraph(6)` raises `TypeError`: write `UGraph(dim=6)`.
38+
- Unpacking the result of a planner without checking for `None`.
39+
- Coordinates in one unit and costs in another (metres against kilometres): the A* heuristic is then too large and the path found is not the shortest.
40+
- `g.component(v)` takes a component *number*; use `v.label` for the component of a vertex.
41+
- Assigning `e.v1 = ...` fails, they are read-only; `add_vertex` raises `ValueError` for a name already used or a vertex that is already in a graph.
42+
- After `build_kdtree()`, change a coordinate by assigning (`v.coord = x`, `v.coord += x`), not in place (`v.coord[0] = x`), which the index cannot see.
43+
- `g.edges()` is a copy: changing the list does not change the graph.

‎docs/llms_txt.py‎

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
"""Write llms.txt and llms-full.txt for the documentation site.
2+
3+
``llms.txt`` (see https://llmstxt.org) is a short Markdown file that tells AI assistants
4+
what the package is, how to use it, and where its documentation lives. ``llms-full.txt``
5+
is the whole documentation as plain text, so an assistant can read it in a single fetch.
6+
7+
The package is small, so ``llms.txt`` is mostly prose: the hand-written ``llms_intro.md``
8+
(what it does, a quick start, key points, common mistakes) followed by a generated list
9+
of links. ``llms-full.txt`` is this Sphinx site rendered by Sphinx's ``text`` builder, so
10+
the autodoc API reference is included, in the order of the toctrees, which are followed
11+
from ``index.rst`` through the nested pages. Run after the HTML build::
12+
13+
make html llms # from docs/
14+
15+
The files are written into ``build/html`` so they are published with the site.
16+
"""
17+
18+
from __future__ import annotations
19+
20+
import os
21+
import re
22+
import shutil
23+
24+
# Used only to re-invoke `sphinx-build`, resolved via shutil.which() below; this is a
25+
# local/CI docs-build script, not code that runs against untrusted input.
26+
import subprocess # nosec B404
27+
import sys
28+
from importlib.metadata import metadata
29+
from pathlib import Path
30+
31+
DOCS = Path(__file__).resolve().parent
32+
SOURCE = DOCS / "source"
33+
BUILD = DOCS / "build"
34+
TEXT = BUILD / "text"
35+
HTML = BUILD / "html"
36+
INTRO = DOCS / "llms_intro.md"
37+
38+
# One-line descriptions of the pages listed in the toctrees
39+
PAGE_NOTES = {
40+
"index": "overview",
41+
"intro": "tutorial: building a graph, vertices, edges, plotting, path planning, directed graphs",
42+
"api": "the API, in three parts: the common methods, vertices and edges, and the policy page",
43+
"common": "the methods that UGraph and DGraph share, by task",
44+
"construction": "UGraph, DGraph, Graph, constructors from a dict or an adjacency matrix, distance metrics",
45+
"modification": "adding and removing vertices and edges",
46+
"analysis": "counts, adjacency, degree, components, coordinates, spatial queries, subgraphs",
47+
"representations": "adjacency, incidence, degree and Laplacian matrices",
48+
"visualization": "plot(), its options, highlighting vertices, edges and paths",
49+
"export": "writing a graph as GraphViz dot, Mermaid, GraphML, ELK or an image",
50+
"planning": "path_BFS, path_UCS and path_Astar",
51+
"edges_vertices": "the data structures: Vertex and Edge, and the edge lists of a vertex",
52+
"policy": "object lifecycle: what may be done with a vertex or edge outside a graph",
53+
}
54+
55+
# Links that belong in llms.txt but are not pages of this site
56+
EXTRA_LINKS = [
57+
("Source code", "https://github.com/petercorke/pgraph-python", "GitHub repository"),
58+
("PyPI", "https://pypi.org/project/pgraph-python/", "install with pip install pgraph-python"),
59+
("Changelog", "https://github.com/petercorke/pgraph-python/blob/main/CHANGELOG.md", "release history and how to migrate from 0.6"),
60+
("Examples", "https://github.com/petercorke/pgraph-python/tree/main/examples", "Queensland and central London route-planning notebooks, and a benchmark"),
61+
("RVC ecosystem", "https://github.com/petercorke/rvc-ecosystem", "how pgraph relates to the Robotics and Machine Vision Toolboxes"),
62+
("Contributing", "https://github.com/petercorke/pgraph-python/blob/main/AGENTS.md", "repo conventions and workflow, for agents working on pgraph's source code"),
63+
]
64+
65+
66+
def toctree_entries(document: Path) -> list[str]:
67+
"""Document names listed in the toctrees of one document, in order.
68+
69+
:param document: an ``.rst`` file
70+
:return: document names such as ``"common"``
71+
"""
72+
entries, in_toctree = [], False
73+
for line in document.read_text().splitlines():
74+
if line.startswith(".. toctree::"):
75+
in_toctree = True
76+
elif in_toctree and line.strip() and not line.startswith((" ", "\t")):
77+
in_toctree = False # the directive's indented body has ended
78+
elif in_toctree and line.strip() and not line.strip().startswith(":"):
79+
entries.append(line.strip())
80+
return entries
81+
82+
83+
def toctree_pages(index: Path) -> list[str]:
84+
"""All the documents below a master document, depth first, in toctree order.
85+
86+
The pages of this site are nested: ``index`` lists ``api``, which lists
87+
``common``, which lists the pages of the common methods.
88+
89+
:param index: the master document, ``index.rst``
90+
:return: document names such as ``"construction"``, each once
91+
"""
92+
pages: list[str] = []
93+
94+
def walk(document: Path) -> None:
95+
for name in toctree_entries(document):
96+
if name not in pages:
97+
pages.append(name)
98+
child = document.parent / f"{name}.rst"
99+
if child.exists():
100+
walk(child)
101+
102+
walk(index)
103+
return pages
104+
105+
106+
def title(text: str) -> str:
107+
"""The page title: the first non-blank line that isn't an overline of punctuation."""
108+
for line in text.splitlines():
109+
if line.strip() and not re.fullmatch(r"[*=\-~#^]+", line.strip()):
110+
return line.strip()
111+
return ""
112+
113+
114+
def main() -> None:
115+
# Read the name, summary and documentation URL from the installed package's metadata
116+
# (built from pyproject.toml). Reading pyproject.toml directly would need tomllib,
117+
# which is Python 3.11+, and the Pages workflow builds the docs on 3.9.
118+
meta = metadata("pgraph-python")
119+
urls = dict(u.split(", ", 1) for u in meta.get_all("Project-URL"))
120+
base = urls["Documentation"].rstrip("/") + "/"
121+
name, summary = meta["Name"], meta["Summary"]
122+
123+
# SPHINXBUILD matches the override convention of docs/Makefile's
124+
# `SPHINXBUILD ?= sphinx-build` -- resolved via shutil.which() to a full path.
125+
sphinxbuild_name = os.environ.get("SPHINXBUILD", "sphinx-build")
126+
sphinxbuild = shutil.which(sphinxbuild_name)
127+
if sphinxbuild is None:
128+
raise SystemExit(f"sphinx-build not found: {sphinxbuild_name!r}")
129+
subprocess.run( # nosec B603 -- sphinxbuild resolved via shutil.which() above # nosemgrep: python.lang.security.audit.dangerous-subprocess-use-audit.dangerous-subprocess-use-audit
130+
[sphinxbuild, "-q", "-b", "text", str(SOURCE), str(TEXT)], # nosemgrep: python.lang.security.audit.dangerous-subprocess-use-tainted-env-args.dangerous-subprocess-use-tainted-env-args
131+
check=True,
132+
)
133+
pages = ["index"] + toctree_pages(SOURCE / "index.rst")
134+
texts = {p: (TEXT / f"{p}.txt").read_text() for p in pages}
135+
136+
# llms.txt: prose introduction, then links
137+
index = [
138+
f"# {name}",
139+
"",
140+
f"> {summary}.",
141+
"",
142+
INTRO.read_text().strip(),
143+
"",
144+
"## Documentation",
145+
"",
146+
f"The whole documentation, including the API reference, is available as a single text file: [llms-full.txt]({base}llms-full.txt).",
147+
"",
148+
*(f"- [{title(texts[p])}]({base}{p}.html): {PAGE_NOTES.get(p, '')}" for p in pages),
149+
"",
150+
"## Optional",
151+
"",
152+
*(f"- [{label}]({url}): {note}" for label, url, note in EXTRA_LINKS),
153+
"",
154+
]
155+
HTML.mkdir(parents=True, exist_ok=True)
156+
(HTML / "llms.txt").write_text("\n".join(index))
157+
158+
# llms-full.txt: the prose introduction, then every page
159+
def block(url: str, body: str) -> str:
160+
return f"\n{'=' * 72}\nPage: {url}\n{'=' * 72}\n\n{body.strip()}\n"
161+
162+
parts = [f"# {name}: {summary}\n\nDocumentation: {base}\n\n{INTRO.read_text().strip()}\n"]
163+
parts += [block(f"{base}{p}.html", texts[p]) for p in pages]
164+
full = re.sub(r"\n{3,}", "\n\n", "".join(parts)) # the text builder leaves runs of blank lines
165+
(HTML / "llms-full.txt").write_text(full)
166+
print(f"llms.txt: {len(pages)} pages; llms-full.txt: {len(full) // 1000} kB", file=sys.stderr)
167+
168+
169+
if __name__ == "__main__":
170+
main()

0 commit comments

Comments
 (0)