langgraph-spec-toolkit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,370 @@
1
+ Metadata-Version: 2.5
2
+ Name: langgraph-spec-toolkit
3
+ Version: 0.1.0
4
+ Summary: MCP server + Claude skill for building LangGraph projects by editing a structured YAML spec instead of regenerating Python each turn
5
+ Project-URL: Homepage, https://github.com/mkrishna-gs/langgraph-spec-toolkit
6
+ Project-URL: Repository, https://github.com/mkrishna-gs/langgraph-spec-toolkit
7
+ Project-URL: Issues, https://github.com/mkrishna-gs/langgraph-spec-toolkit/issues
8
+ Author-email: Murali Krishna Ganesa Subramanian <44777244+mkrishna-gs@users.noreply.github.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: agents,codegen,langgraph,llm,mcp,model-context-protocol
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Software Development :: Code Generators
19
+ Requires-Python: >=3.11
20
+ Requires-Dist: jinja2>=3.1
21
+ Requires-Dist: mcp>=1.2.0
22
+ Requires-Dist: pyyaml>=6.0
23
+ Description-Content-Type: text/markdown
24
+
25
+ # langgraph-spec-toolkit
26
+
27
+ [![CI](https://github.com/mkrishna-gs/langgraph-spec-toolkit/actions/workflows/ci.yml/badge.svg)](https://github.com/mkrishna-gs/langgraph-spec-toolkit/actions/workflows/ci.yml)
28
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
29
+ [![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-blue.svg)](pyproject.toml)
30
+ [![Status: v0.1 alpha](https://img.shields.io/badge/status-v0.1%20alpha-orange.svg)](#project-status)
31
+
32
+ **An MCP server + Claude skill for building [LangGraph](https://github.com/langchain-ai/langgraph) projects by editing a structured YAML spec — not by regenerating Python from scratch on every turn.**
33
+
34
+ ```
35
+ edit spec.yaml (via MCP tools) → validate_graph → render_python → graph.py
36
+ ```
37
+
38
+ Graph topology — nodes, edges, state schema, checkpointer — is data, not
39
+ prose. An LLM agent should be able to add a node or rewire an edge with one
40
+ small, targeted tool call, not re-emit 150 lines of Python and hope nothing
41
+ upstream broke. `spec.yaml` is the source of truth; `graph.py` is a
42
+ deterministic, regenerable build artifact you never hand-edit.
43
+
44
+ ## Table of contents
45
+
46
+ - [Why](#why)
47
+ - [Prior art](#prior-art)
48
+ - [Project status](#project-status)
49
+ - [Installation](#installation)
50
+ - [Usage](#usage)
51
+ - [The spec format](#the-spec-format)
52
+ - [MCP tools](#mcp-tools)
53
+ - [Validation](#validation)
54
+ - [Example](#example)
55
+ - [Benchmarks](#benchmarks)
56
+ - [Development](#development)
57
+ - [Releasing](#releasing)
58
+ - [Contributing](#contributing)
59
+ - [License](#license)
60
+
61
+ ## Why
62
+
63
+ - **Token cost.** A full-file rewrite scales with graph size on every edit;
64
+ a spec edit doesn't. Measured with [`benchmarks/token_usage.py`](benchmarks/token_usage.py)
65
+ (`uv run python benchmarks/token_usage.py`, no network access or vendor
66
+ SDK required — see the script for what "token" means here):
67
+
68
+ | Nodes in graph | Full `graph.py` regen (tokens) | One spec edit (tokens) | Ratio |
69
+ |---:|---:|---:|---:|
70
+ | 3 | 168 | 21 | 8.0x |
71
+ | 5 | 204 | 21 | 9.7x |
72
+ | 10 | 294 | 21 | 14.0x |
73
+ | 25 | 564 | 21 | 26.9x |
74
+ | 50 | 1014 | 21 | 48.3x |
75
+ | 100 | 1914 | 21 | 91.1x |
76
+
77
+ A single spec edit stays flat regardless of graph size; a full-file
78
+ regen grows linearly with it. Token counts use a small offline
79
+ approximate tokenizer, not a specific vendor's real BPE tokenizer, so
80
+ the numbers are illustrative rather than exact — the shape of the curve
81
+ (flat vs. linear) is the actual claim, and holds under any reasonable
82
+ way of counting.
83
+ - **Error rate.** Free-form Python regeneration risks silently dropping an
84
+ edge, mistyping a state key, or producing an unreachable node. A
85
+ structured spec can be validated *before* any code is emitted.
86
+ - **Diffability.** `spec.yaml` changes are small, reviewable diffs. A
87
+ regenerated file's diff is often the whole file.
88
+
89
+ ## Prior art
90
+
91
+ [`langgraph-codegen`](https://pypi.org/project/langgraph-codegen/) already
92
+ does DSL → Python codegen for LangGraph and is worth a look. It ships as a
93
+ library/CLI, without an MCP server, a validation pass, diagramming, or a
94
+ skill layer for an LLM to drive it interactively — that's the gap this
95
+ project fills. We use our own spec format rather than adopting its DSL.
96
+
97
+ ## Project status
98
+
99
+ **v0.1 (current, `0.1.0`)** — first cut, functional end-to-end on a single
100
+ flat graph:
101
+
102
+ - Spec schema: state fields, nodes, edges (simple + conditional), checkpointer.
103
+ - MCP tools: `init_project`, `add_node`, `add_edge`, `remove_node`,
104
+ `remove_edge`, `set_state_schema`, `get_spec`, `validate_graph`,
105
+ `render_python`.
106
+ - Validation: unreachable nodes, missing path to `END`, dangling
107
+ conditions/edges, duplicate/typo'd ids, unsafe identifiers in
108
+ `function`/`condition`/state field names/reducers.
109
+ - Deterministic Jinja2 codegen — no LLM in the render path.
110
+ - `pytest` suite covering the spec model, validator, renderer, and every
111
+ MCP tool.
112
+
113
+ Out of scope for v0.1: diagramming, subgraphs, multi-file projects, and a
114
+ `langgraph-codegen`-style DSL importer. This is a young project; expect the
115
+ spec schema and tool signatures to evolve before 1.0.
116
+
117
+ ## Installation
118
+
119
+ Requires Python 3.11+ and [uv](https://docs.astral.sh/uv/) (which provides
120
+ `uvx`).
121
+
122
+ **Via `uvx`** (recommended once a release is published — no clone, no local
123
+ install; `uvx` fetches and runs it on demand):
124
+
125
+ ```json
126
+ {
127
+ "mcpServers": {
128
+ "langgraph-spec-toolkit": {
129
+ "command": "uvx",
130
+ "args": ["langgraph-spec-toolkit"]
131
+ }
132
+ }
133
+ }
134
+ ```
135
+
136
+ **From source** (needed until the first PyPI release lands, or if you're
137
+ developing on the toolkit itself):
138
+
139
+ ```bash
140
+ git clone <this-repo>
141
+ cd langgraph-spec-toolkit
142
+ uv sync
143
+ ```
144
+
145
+ ```json
146
+ {
147
+ "mcpServers": {
148
+ "langgraph-spec-toolkit": {
149
+ "command": "uv",
150
+ "args": ["run", "--directory", "/path/to/langgraph-spec-toolkit", "python", "-m", "mcp_server.server"]
151
+ }
152
+ }
153
+ }
154
+ ```
155
+
156
+ Runtime dependencies are intentionally minimal: `mcp`, `jinja2`, `pyyaml`.
157
+ `render_python`'s *output* imports `langgraph` (and `langchain-core`, if
158
+ your state uses message types) — those are dependencies of the project
159
+ you're generating, not of this toolkit.
160
+
161
+ ## Usage
162
+
163
+ Either config above starts the MCP server (it speaks MCP over stdio) the
164
+ moment your client connects — there's no separate "run the server" step to
165
+ do by hand. If you want to smoke-test it directly:
166
+
167
+ ```bash
168
+ uv run python -m mcp_server.server # from a source checkout
169
+ uvx langgraph-spec-toolkit # once published
170
+ ```
171
+
172
+ Then drive it through the tools below — or point Claude at `skill/SKILL.md`
173
+ and let it drive itself. A typical session:
174
+
175
+ ```
176
+ init_project(project_dir="my_graph", name="my_graph")
177
+ add_node(project_dir="my_graph", id="start", config={"function": "start"})
178
+ add_node(project_dir="my_graph", id="respond", config={"function": "respond"})
179
+ add_edge(project_dir="my_graph", from_="start", to="respond")
180
+ add_edge(project_dir="my_graph", from_="respond", to="END")
181
+ validate_graph(project_dir="my_graph") # -> ok: true
182
+ render_python(project_dir="my_graph") # -> writes my_graph/graph.py
183
+ ```
184
+
185
+ ...then write `start`/`respond` in `my_graph/nodes.py` and you have a
186
+ runnable graph.
187
+
188
+ ## The spec format
189
+
190
+ `spec.yaml`:
191
+
192
+ ```yaml
193
+ name: simple_chatbot
194
+ entry_point: greet
195
+ state:
196
+ - name: messages
197
+ type: list[BaseMessage]
198
+ reducer: add_messages
199
+ default: []
200
+ nodes:
201
+ - id: greet
202
+ type: python
203
+ config:
204
+ function: greet # callable in nodes.py; defaults to the node id
205
+ - id: chatbot
206
+ type: python
207
+ config:
208
+ function: chatbot
209
+ - id: tools
210
+ type: python
211
+ config:
212
+ function: call_tools
213
+ edges:
214
+ - from: greet
215
+ to: chatbot
216
+ - from: chatbot
217
+ condition: route_after_chatbot # router fn in nodes.py
218
+ paths:
219
+ continue: tools
220
+ end: END
221
+ - from: tools
222
+ to: chatbot
223
+ checkpointer:
224
+ type: none # none | memory | sqlite | postgres
225
+ ```
226
+
227
+ Node and router **bodies are not generated** — `render_python` only owns
228
+ topology, state, and wiring. You write the callables in the project's
229
+ `nodes.py`, named to match `config.function` / `condition`. This keeps
230
+ codegen deterministic: the same spec always renders to the same Python, and
231
+ business logic never gets silently rewritten on a regen.
232
+
233
+ `type` on a state field is a raw Python type expression. A handful of
234
+ common symbols — `BaseMessage`, `AnyMessage`, `HumanMessage`, `AIMessage`,
235
+ `SystemMessage`, `ToolMessage`, `ChatMessage`, plus `Any` / `Optional` /
236
+ `Sequence` / `Union` / `Literal` from `typing` — are recognized by name and
237
+ auto-imported in the rendered file. `reducer` similarly recognizes
238
+ `add_messages` and `add` / `operator.add` as built-ins; anything else is
239
+ assumed to be a function you define in `reducers.py`.
240
+
241
+ ## MCP tools
242
+
243
+ | Tool | Purpose |
244
+ |---|---|
245
+ | `init_project(project_dir, name, state_fields?)` | Scaffold `spec.yaml`, `nodes.py`, `__init__.py`. |
246
+ | `add_node(project_dir, id, type?, config?, entry_point?)` | Add/update a node. The first node added becomes `entry_point` automatically. |
247
+ | `add_edge(project_dir, from_, to?, condition?, paths?)` | Add a simple (`to`) or conditional (`condition` + `paths`) edge. |
248
+ | `remove_node(project_dir, id)` | Remove a node; cascades to delete edges touching it. |
249
+ | `remove_edge(project_dir, from_, to?)` | Remove edge(s) from a source, optionally to one target. |
250
+ | `set_state_schema(project_dir, fields)` | Replace the state schema wholesale. |
251
+ | `get_spec(project_dir)` | Read-only fetch of the full current spec. |
252
+ | `validate_graph(project_dir)` | Run static checks; returns `ok` + a list of issues. |
253
+ | `render_python(project_dir, output_path?)` | Emit `graph.py` (default: `<project_dir>/graph.py`). Blocks on validation *errors*. |
254
+
255
+ > **Note:** edges use the parameter name `from_`, not `from` — the latter
256
+ > is a reserved word in Python. It still round-trips through the `from:`
257
+ > key in `spec.yaml`.
258
+
259
+ > **Note:** the mutating tools (`add_node`, `add_edge`, `remove_node`,
260
+ > `remove_edge`, `set_state_schema`) return a compact `summary` (node/edge/
261
+ > state counts, entry point, checkpointer type) rather than the full spec —
262
+ > echoing the whole graph back on every small edit would grow with graph
263
+ > size and quietly erode the token savings this toolkit exists for. Call
264
+ > `get_spec` when you actually need the full picture.
265
+
266
+ ## Validation
267
+
268
+ `validate_graph` checks for:
269
+
270
+ - **Unreachable nodes** — no path from `entry_point`.
271
+ - **Missing path to `END`** — a node that can never terminate the graph.
272
+ - **Dangling conditions** — a conditional edge with no `paths`, or a `paths`
273
+ target that isn't a real node id (or `END`).
274
+ - **State/id typos** — duplicate node ids, duplicate state field names, an
275
+ `entry_point` that doesn't match any node id, an unknown checkpointer type.
276
+ - **Unsafe identifiers** — `config.function`, a conditional edge's
277
+ `condition`, a state field's `name`, and a non-builtin `reducer` are all
278
+ spliced into the generated Python unquoted (e.g. `nodes.<function>`), so
279
+ each must be a valid Python identifier; a state field's `type` must at
280
+ least parse as a Python expression. This is a correctness *and* safety
281
+ check — it's the boundary that keeps a bad spec value from becoming
282
+ arbitrary code in `graph.py`.
283
+
284
+ `render_python` refuses to emit code while validation *errors* are present;
285
+ warnings (like an unreachable node) don't block rendering.
286
+
287
+ ## Example
288
+
289
+ [`examples/simple_chatbot`](examples/simple_chatbot) has a spec with a
290
+ message-reducer state field, a linear edge, and a conditional tool-call
291
+ loop, plus the generated `graph.py` — diff the two to see exactly what
292
+ codegen does. It's been exercised end-to-end against a real `langgraph` +
293
+ `langchain-core` install to confirm the generated wiring executes, not just
294
+ that it parses.
295
+
296
+ ## Benchmarks
297
+
298
+ [`benchmarks/token_usage.py`](benchmarks/token_usage.py) measures the
299
+ token-cost claim in [Why](#why): full `graph.py` regeneration vs. a single
300
+ spec-tool edit, across graph sizes from 3 to 100 nodes.
301
+
302
+ ```bash
303
+ uv run python benchmarks/token_usage.py
304
+ ```
305
+
306
+ No network access or vendor SDK required — see the script's docstring for
307
+ what it counts as a "token" and why.
308
+
309
+ ## Development
310
+
311
+ ```bash
312
+ uv sync
313
+ uv run python -m mcp_server.server # smoke-test the server starts
314
+ uv run pytest # run the test suite
315
+ uv run ruff check . # lint
316
+ ```
317
+
318
+ The test suite (`tests/`) covers `spec.py` (dataclasses, YAML round-trips),
319
+ `validator/` (every check, including the identifier/injection-safety ones),
320
+ `renderer/` (codegen against the committed example, plus each reducer/
321
+ checkpointer variant), every MCP tool's `run()` function, and MCP tool
322
+ registration itself. New tools or spec fields should come with tests in
323
+ the matching file.
324
+
325
+ CI (`.github/workflows/ci.yml`) runs lint and the test suite (on Python
326
+ 3.11 and 3.12) on every push and pull request against `main`.
327
+
328
+ ## Releasing
329
+
330
+ Publishing to PyPI (`.github/workflows/publish.yml`) uses
331
+ [Trusted Publishing](https://docs.pypi.org/trusted-publishers/) — no API
332
+ token is stored in this repo. One-time setup (maintainers only):
333
+
334
+ 1. On [pypi.org](https://pypi.org), add a trusted publisher for this
335
+ project: owner `mkrishna-gs`, repo `langgraph-spec-toolkit`, workflow
336
+ `publish.yml`, environment `pypi`. (If the project doesn't exist on
337
+ PyPI yet, PyPI supports adding a trusted publisher for a
338
+ not-yet-published project name — it claims the name on first publish.)
339
+ 2. In this repo's GitHub settings, create an environment named `pypi`
340
+ (optionally with required reviewers, for an extra manual gate before
341
+ every publish).
342
+
343
+ After that, cutting a release is the whole process:
344
+
345
+ 1. Bump `version` in `pyproject.toml`.
346
+ 2. Tag and push, then publish a GitHub Release from that tag (or use
347
+ `gh release create`).
348
+ 3. `publish.yml` builds the sdist/wheel and publishes them automatically.
349
+
350
+ ## Contributing
351
+
352
+ Issues and pull requests are welcome. For anything beyond a small fix,
353
+ please open an issue first to discuss scope — the spec schema and tool
354
+ signatures are still settling in this pre-1.0 phase, and larger changes are
355
+ easier to land as a shared plan than as a surprise diff.
356
+
357
+ Before opening a PR:
358
+
359
+ 1. `uv sync` and confirm `uv run python -m mcp_server.server` starts cleanly.
360
+ 2. `uv run pytest` and `uv run ruff check .` both pass. New tools or spec
361
+ fields need tests alongside them.
362
+ 3. Keep runtime dependencies to `mcp`, `jinja2`, `pyyaml` — anything else
363
+ belongs in the generated project, not this toolkit (test-only deps go in
364
+ `[dependency-groups.dev]`).
365
+ 4. Keep `render_python` deterministic: no LLM calls, no non-reproducible
366
+ output, in the render path.
367
+
368
+ ## License
369
+
370
+ [MIT](LICENSE)
@@ -0,0 +1,23 @@
1
+ mcp_server/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ mcp_server/server.py,sha256=ZR00isSbfjeuymlKMEiRue48mgtvaJ-r8xg8_P9C01k,5480
3
+ mcp_server/spec.py,sha256=4E24rNwSh2PEuNIBURHvCZZamSwH6O_yMOqVQQHFyv0,6568
4
+ mcp_server/renderer/__init__.py,sha256=zKj5SinBxhn7mIgVXmGEHTYs8Mc0d6wP8-DnMkEeotU,75
5
+ mcp_server/renderer/render.py,sha256=rWVPVYEyDRbH6sOrIB_ia9hSRXP9PqIFIu0PzWYltLc,6436
6
+ mcp_server/renderer/templates/graph.py.jinja2,sha256=odZBBBhCQNvNIlRk1OFq8A_N_zIRlLFRLXYRNMV_p0E,1177
7
+ mcp_server/tools/__init__.py,sha256=T48emoTIBaHFSDXZdTeDcYFCHhhIkhOjBNRJlaTGzzw,361
8
+ mcp_server/tools/add_edge.py,sha256=UrOqu7b-9uB4me8G1xWZrUyb0L6hJQyas59e9wI5M20,1046
9
+ mcp_server/tools/add_node.py,sha256=gjv0BBaLzlZ6-3SmwNFh9SK3eBoE0nlu3qFHRTantqA,1104
10
+ mcp_server/tools/get_spec.py,sha256=YnAUeJvrQGOhX909IjlCsVRCt-YWrAOKWe9zJq_UEvM,657
11
+ mcp_server/tools/init_project.py,sha256=YAbZk32pcaI-9gSMx39Q7JsabQstyg1yXjImxMExlRU,1786
12
+ mcp_server/tools/remove_edge.py,sha256=4JeqJXp45e21CfRl-UTFIwUO7d7_HGR0B9TaEhqxqsA,779
13
+ mcp_server/tools/remove_node.py,sha256=vZFNMkfNQJorL9hy20rM6MMV7_kIPaNEWg0WL_CRUaE,1137
14
+ mcp_server/tools/render_python.py,sha256=MvbDL-sCdPWcBCg8-3pyy5OIc1_M44s6FU5HWOqqWKk,1195
15
+ mcp_server/tools/set_state_schema.py,sha256=k4lsMjtfiM4nIg93bcVsBEtm9QMFAvOtFawalK2Ds3Q,479
16
+ mcp_server/tools/validate_graph.py,sha256=JsOwqY4Nja28WSzD2niXTFDfIf9vWg703zaeGX3TGCA,563
17
+ mcp_server/validator/__init__.py,sha256=eio-OvqDOl1llqguvFNSWXLpxWTqViWjBi0L1L8skMA,93
18
+ mcp_server/validator/validate.py,sha256=dvSvsmO8QeovtZvx37Ev1iSjobKFMkG7mDQgtUBemQ8,11988
19
+ langgraph_spec_toolkit-0.1.0.dist-info/METADATA,sha256=qQiBAOb8iT8-XiIUGxb-KQkdxPq0dfKH9cwcLBU4MlY,14812
20
+ langgraph_spec_toolkit-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
21
+ langgraph_spec_toolkit-0.1.0.dist-info/entry_points.txt,sha256=TC2dvn_1_x6hu-_TOrAo_otKjCpwCWarq1YyBxNVtFM,66
22
+ langgraph_spec_toolkit-0.1.0.dist-info/licenses/LICENSE,sha256=7LHoOawH3HVKPRG_XUiyqvemkJOlfwBDwwYLYi8yWVI,1068
23
+ langgraph_spec_toolkit-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ langgraph-spec-toolkit = mcp_server.server:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 mkrishna-gs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
mcp_server/__init__.py ADDED
File without changes
@@ -0,0 +1,3 @@
1
+ from .render import render_graph_python
2
+
3
+ __all__ = ["render_graph_python"]
@@ -0,0 +1,184 @@
1
+ """Deterministic spec -> Python codegen.
2
+
3
+ No LLM in this path: every code fragment is either a fixed template line or
4
+ a mechanical transform of spec data (repr()'d literals, sorted imports).
5
+ Same spec.yaml in, byte-identical graph.py out.
6
+
7
+ Node callables and condition/router functions are *not* generated — they're
8
+ plain Python the developer writes in `nodes.py` next to the generated
9
+ `graph.py`, referenced by name from the spec. Codegen only owns topology,
10
+ state schema, and wiring.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from pathlib import Path
17
+
18
+ from jinja2 import Environment, FileSystemLoader, StrictUndefined
19
+
20
+ from ..spec import BUILTIN_REDUCER_NAMES, END, GraphSpec
21
+
22
+ TEMPLATES_DIR = Path(__file__).parent / "templates"
23
+
24
+ _IDENTIFIER_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
25
+
26
+ # Symbols that commonly show up in `type:` strings but aren't builtins.
27
+ # Scanned for by name so a field like `type: list[BaseMessage]` gets its
28
+ # import pulled in automatically instead of producing a NameError at runtime.
29
+ _TYPING_SYMBOLS = {"Any", "Optional", "Sequence", "Union", "Literal"}
30
+ _LANGCHAIN_MESSAGE_SYMBOLS = {
31
+ "BaseMessage",
32
+ "AnyMessage",
33
+ "HumanMessage",
34
+ "AIMessage",
35
+ "SystemMessage",
36
+ "ToolMessage",
37
+ "ChatMessage",
38
+ }
39
+
40
+ # reducer name -> (import line, expression to use in Annotated[...])
41
+ _BUILTIN_REDUCERS: dict[str, tuple[str, str]] = {
42
+ "add_messages": ("from langgraph.graph.message import add_messages", "add_messages"),
43
+ "add": ("import operator", "operator.add"),
44
+ "operator.add": ("import operator", "operator.add"),
45
+ }
46
+ assert set(_BUILTIN_REDUCERS) == BUILTIN_REDUCER_NAMES, (
47
+ "renderer's builtin reducer table drifted from spec.BUILTIN_REDUCER_NAMES "
48
+ "(the validator's identifier check relies on these matching)"
49
+ )
50
+
51
+ # checkpointer.type -> (import line, constructor expression)
52
+ _CHECKPOINTER_IMPORTS: dict[str, tuple[str, str]] = {
53
+ "memory": ("from langgraph.checkpoint.memory import MemorySaver", "MemorySaver()"),
54
+ "sqlite": (
55
+ "from langgraph.checkpoint.sqlite import SqliteSaver",
56
+ # from_conn_string() is a context manager upstream; this inline call
57
+ # is a v0.1 placeholder — wire it to your app's lifecycle for real use.
58
+ 'SqliteSaver.from_conn_string("checkpoints.sqlite")',
59
+ ),
60
+ "postgres": (
61
+ "from langgraph.checkpoint.postgres import PostgresSaver",
62
+ 'PostgresSaver.from_conn_string("<POSTGRES_CONNECTION_STRING>")',
63
+ ),
64
+ }
65
+
66
+
67
+ def _target_literal(target: str) -> str:
68
+ return "END" if target == END else repr(target)
69
+
70
+
71
+ def _reducer_import_and_expr(reducer: str) -> tuple[str | None, str]:
72
+ if reducer in _BUILTIN_REDUCERS:
73
+ return _BUILTIN_REDUCERS[reducer]
74
+ # unrecognized reducer name: assume a user-supplied function in reducers.py
75
+ return "from . import reducers", f"reducers.{reducer}"
76
+
77
+
78
+ def _dedup(items: list[str]) -> list[str]:
79
+ seen: set[str] = set()
80
+ out: list[str] = []
81
+ for item in items:
82
+ if item not in seen:
83
+ seen.add(item)
84
+ out.append(item)
85
+ return out
86
+
87
+
88
+ class RenderError(ValueError):
89
+ """Raised when a spec can't be rendered into valid Python (e.g. no entry_point)."""
90
+
91
+
92
+ def render_graph_python(spec: GraphSpec) -> str:
93
+ if not spec.entry_point:
94
+ raise RenderError(
95
+ "spec has no entry_point set — call set_state_schema/add_node first, "
96
+ "then set entry_point, or run validate_graph for the full list of issues"
97
+ )
98
+
99
+ extra_imports: list[str] = []
100
+
101
+ typing_names: set[str] = {"TypedDict"}
102
+ if any(f.reducer for f in spec.state):
103
+ typing_names.add("Annotated")
104
+ langchain_message_names: set[str] = set()
105
+
106
+ state_fields = []
107
+ for f in spec.state:
108
+ for identifier in _IDENTIFIER_RE.findall(f.type):
109
+ if identifier in _TYPING_SYMBOLS:
110
+ typing_names.add(identifier)
111
+ elif identifier in _LANGCHAIN_MESSAGE_SYMBOLS:
112
+ langchain_message_names.add(identifier)
113
+
114
+ if f.reducer:
115
+ imp, expr = _reducer_import_and_expr(f.reducer)
116
+ if imp:
117
+ extra_imports.append(imp)
118
+ annotation = f"Annotated[{f.type}, {expr}]"
119
+ else:
120
+ annotation = f.type
121
+ state_fields.append({"name": f.name, "annotation": annotation})
122
+
123
+ if langchain_message_names:
124
+ extra_imports.append(
125
+ "from langchain_core.messages import " + ", ".join(sorted(langchain_message_names))
126
+ )
127
+
128
+ if spec.nodes or any(e.is_conditional for e in spec.edges):
129
+ extra_imports.append("from . import nodes")
130
+
131
+ node_ctx = [
132
+ {"id_literal": repr(n.id), "function": n.config.get("function", n.id)}
133
+ for n in spec.nodes
134
+ ]
135
+
136
+ simple_edges = []
137
+ conditional_edges = []
138
+ for e in spec.edges:
139
+ if e.is_conditional:
140
+ conditional_edges.append(
141
+ {
142
+ "from_literal": repr(e.from_),
143
+ "condition_ref": f"nodes.{e.condition}",
144
+ "paths": [
145
+ (repr(label), _target_literal(target))
146
+ for label, target in (e.paths or {}).items()
147
+ ],
148
+ }
149
+ )
150
+ else:
151
+ simple_edges.append(
152
+ {"from_literal": repr(e.from_), "to_literal": _target_literal(e.to)}
153
+ )
154
+
155
+ checkpointer_instantiate = None
156
+ if spec.checkpointer.type in _CHECKPOINTER_IMPORTS:
157
+ imp, expr = _CHECKPOINTER_IMPORTS[spec.checkpointer.type]
158
+ extra_imports.append(imp)
159
+ checkpointer_instantiate = expr
160
+
161
+ imports = [
162
+ "from langgraph.graph import StateGraph, START, END",
163
+ f"from typing import {', '.join(sorted(typing_names))}",
164
+ ] + _dedup(extra_imports)
165
+
166
+ env = Environment(
167
+ loader=FileSystemLoader(str(TEMPLATES_DIR)),
168
+ trim_blocks=True,
169
+ lstrip_blocks=True,
170
+ undefined=StrictUndefined,
171
+ keep_trailing_newline=True,
172
+ )
173
+ template = env.get_template("graph.py.jinja2")
174
+ return template.render(
175
+ spec_name=spec.name,
176
+ state_class_name="GraphState",
177
+ imports=imports,
178
+ state_fields=state_fields,
179
+ nodes=node_ctx,
180
+ entry_point_literal=repr(spec.entry_point) if spec.entry_point else None,
181
+ simple_edges=simple_edges,
182
+ conditional_edges=conditional_edges,
183
+ checkpointer_instantiate=checkpointer_instantiate,
184
+ )
@@ -0,0 +1,49 @@
1
+ """Auto-generated by langgraph-spec-toolkit — DO NOT EDIT BY HAND.
2
+
3
+ Regenerate with the `render_python` MCP tool after changing spec.yaml.
4
+ Source spec: {{ spec_name }}
5
+ """
6
+
7
+ {% for line in imports %}
8
+ {{ line }}
9
+ {% endfor %}
10
+
11
+
12
+ class {{ state_class_name }}(TypedDict):
13
+ {% for f in state_fields %}
14
+ {{ f.name }}: {{ f.annotation }}
15
+ {% endfor %}
16
+ {% if not state_fields %}
17
+ pass
18
+ {% endif %}
19
+
20
+
21
+ def build_graph():
22
+ workflow = StateGraph({{ state_class_name }})
23
+
24
+ {% for n in nodes %}
25
+ workflow.add_node({{ n.id_literal }}, nodes.{{ n.function }})
26
+ {% endfor %}
27
+
28
+ workflow.add_edge(START, {{ entry_point_literal }})
29
+ {% for e in simple_edges %}
30
+ workflow.add_edge({{ e.from_literal }}, {{ e.to_literal }})
31
+ {% endfor %}
32
+ {% for c in conditional_edges %}
33
+ workflow.add_conditional_edges(
34
+ {{ c.from_literal }},
35
+ {{ c.condition_ref }},
36
+ {
37
+ {% for label, target in c.paths %}
38
+ {{ label }}: {{ target }},
39
+ {% endfor %}
40
+ },
41
+ )
42
+ {% endfor %}
43
+
44
+ {% if checkpointer_instantiate %}
45
+ checkpointer = {{ checkpointer_instantiate }}
46
+ return workflow.compile(checkpointer=checkpointer)
47
+ {% else %}
48
+ return workflow.compile()
49
+ {% endif %}