Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
#!/usr/bin/env python3
"""Generate src/kicad_mcp/toolsets/*.py from tests/fixtures/tools_baseline.json.
The baseline is the exact tool surface the TypeScript layer registered
(captured from its zod schemas). Each tool becomes a typed function whose
signature reproduces the JSON schema via type hints + pydantic Field, and
whose body dispatches into the existing command router.
Regenerate after editing the baseline: python scripts/gen_toolsets.py
The generated files are committed source; hand-edits are allowed once a
module stops being regenerated (delete its name from the baseline first).
"""
import json
import keyword
import re
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
REPO = Path(__file__).resolve().parent.parent
BASELINE = REPO / "tests" / "fixtures" / "tools_baseline.json"
OUT_DIR = REPO / "src" / "kicad_mcp" / "toolsets"
# Hand-written modules the generator must not touch.
SKIP_MODULES = {"router"}
HEADER = '''"""{title} toolset — GENERATED by scripts/gen_toolsets.py, do not hand-edit.
Source of truth: tests/fixtures/tools_baseline.json (module "{module}").
"""
from __future__ import annotations
from typing import Annotated, Any, Dict, List, Literal, Optional, Union
from pydantic import BaseModel, Field
from kicad_mcp.toolsets._common import dispatch_command
'''
def camel(s: str) -> str:
return "".join(p.capitalize() for p in re.split(r"[_\-]+", s) if p)
def esc_str(s: str) -> str:
return json.dumps(s) # JSON string escaping is valid Python
def esc_docstring(s: str) -> str:
return s.replace("\\", "\\\\").replace('"""', '\\"\\"\\"')
class ModuleGen:
def __init__(self) -> None:
self.model_blocks: List[str] = []
self.model_names: set = set()
def py_type(self, schema: Dict[str, Any], ctx: str) -> Tuple[str, Dict[str, Any]]:
"""Return (type expression, Field kwargs) for a JSON schema node."""
kwargs: Dict[str, Any] = {}
if "description" in schema:
kwargs["description"] = schema["description"]
t = schema.get("type")
if isinstance(t, list):
parts = [self.py_type({"type": x}, ctx)[0] for x in t]
return "Union[" + ", ".join(dict.fromkeys(parts)) + "]", kwargs
if "anyOf" in schema:
return "Any", kwargs
if "enum" in schema and all(isinstance(v, str) for v in schema["enum"]):
lit = ", ".join(esc_str(v) for v in schema["enum"])
return f"Literal[{lit}]", kwargs
if t == "string":
if "minLength" in schema:
kwargs["min_length"] = schema["minLength"]
if "maxLength" in schema:
kwargs["max_length"] = schema["maxLength"]
return "str", kwargs
if t == "number":
if "minimum" in schema:
kwargs["ge"] = schema["minimum"]
if "maximum" in schema:
kwargs["le"] = schema["maximum"]
return "float", kwargs
if t == "integer":
if "minimum" in schema:
kwargs["ge"] = schema["minimum"]
if "maximum" in schema:
kwargs["le"] = schema["maximum"]
return "int", kwargs
if t == "boolean":
return "bool", kwargs
if t == "array":
if "minItems" in schema:
kwargs["min_length"] = schema["minItems"]
if "maxItems" in schema:
kwargs["max_length"] = schema["maxItems"]
item_t = "Any"
if isinstance(schema.get("items"), dict):
item_t, _ = self.py_type(schema["items"], ctx + "Item")
return f"List[{item_t}]", kwargs
if t == "object":
props = schema.get("properties")
if props:
return self.emit_model(ctx, schema), kwargs
return "Dict[str, Any]", kwargs
return "Any", kwargs
def emit_model(self, name: str, schema: Dict[str, Any]) -> str:
base = name
n = 2
while name in self.model_names:
name = f"{base}{n}"
n += 1
self.model_names.add(name)
required = set(schema.get("required", []))
lines = [f"class {name}(BaseModel):"]
doc = schema.get("description")
if doc:
lines.append(f' """{esc_docstring(doc)}"""')
lines.append("")
for prop, sub in schema["properties"].items():
t_expr, kw = self.py_type(sub, name + camel(prop))
ann, default = self.render_annotation(t_expr, kw, prop in required, sub)
lines.append(f" {prop}: {ann}{default}")
self.model_blocks.append("\n".join(lines) + "\n")
return name
@staticmethod
def render_annotation(
t_expr: str,
kw: Dict[str, Any],
required: bool,
schema: Dict[str, Any],
) -> Tuple[str, str]:
"""Return (annotation, ' = default' suffix)."""
default = ""
if not required:
if "default" in schema:
default = f" = {schema['default']!r}"
else:
t_expr = f"Optional[{t_expr}]"
default = " = None"
if kw:
parts = []
for k, v in kw.items():
rendered = esc_str(v) if isinstance(v, str) else repr(v)
parts.append(f"{k}={rendered}")
ann = f"Annotated[{t_expr}, Field({', '.join(parts)})]"
else:
ann = t_expr
return ann, default
def emit_tool(self, tool: Dict[str, Any]) -> str:
name = tool["name"]
schema = tool["inputSchema"]
props: Dict[str, Any] = schema.get("properties") or {}
required = set(schema.get("required", []))
params: List[Tuple[str, str, str]] = [] # (prop, annotation, default)
# Required params first (Python demands defaults trail), original
# order preserved within each group.
for group_required in (True, False):
for prop, sub in props.items():
if (prop in required) is not group_required:
continue
if not prop.isidentifier() or keyword.iskeyword(prop):
raise ValueError(f"{name}: property {prop!r} is not a valid identifier")
t_expr, kw = self.py_type(sub, camel(name) + camel(prop))
ann, default = self.render_annotation(t_expr, kw, prop in required, sub)
params.append((prop, ann, default))
sig_lines = "".join(f"\n {p}: {a}{d}," for p, a, d in params)
args_dict = ", ".join(f'"{p}": {p}' for p, _, _ in params)
doc = esc_docstring(tool["description"] or f"KiCAD command: {name}")
return (
f"def {name}({sig_lines}\n) -> dict[str, Any]:\n"
f' """{doc}"""\n'
f' return dispatch_command("{name}", {{{args_dict}}})\n'
if params
else f"def {name}() -> dict[str, Any]:\n"
f' """{doc}"""\n'
f' return dispatch_command("{name}", {{}})\n'
)
def main() -> None:
tools = json.loads(BASELINE.read_text(encoding="utf-8"))
by_module: Dict[str, List[Dict[str, Any]]] = {}
for t in tools:
by_module.setdefault(t["module"], []).append(t)
generated = []
for module, mod_tools in sorted(by_module.items()):
if module in SKIP_MODULES:
continue
py_name = module.replace("-", "_")
gen = ModuleGen()
bodies = [gen.emit_tool(t) for t in mod_tools]
fn_names = [t["name"] for t in mod_tools]
parts = [HEADER.format(title=module, module=module)]
if gen.model_blocks:
parts.append("\n\n".join(gen.model_blocks) + "\n")
parts.append("\n\n".join(bodies))
parts.append(
"\n\n_TOOLS = [\n"
+ "".join(f" {n},\n" for n in fn_names)
+ "]\n\n\n"
"def register(mcp: Any) -> None:\n"
" for fn in _TOOLS:\n"
" mcp.tool()(fn)\n"
)
out = OUT_DIR / f"{py_name}.py"
out.write_text("".join(parts), encoding="utf-8")
generated.append((py_name, len(mod_tools)))
total = sum(c for _, c in generated)
for py_name, count in generated:
print(f" {py_name}: {count}")
print(f"generated {total} tools across {len(generated)} modules -> {OUT_DIR}")
if __name__ == "__main__":
main()
6661ceba9This file is pinned to the selected release. Download the original to open it in your design tool.