Portability + consistency polish (Codex review)
Housekeeping over features, per Codex: consistency and portability now matter more than another feature. - driver.scene_summary no longer hardcodes ~/PycharmProjects/.venv/bin/woodshop; new driver.woodshop_cmd() resolves the CLI portably (PATH, else `python -m woodshop`). Used by the voice/GUI status path. - scripts/gen_wood_tools.py: CMDFORGE_PY overridable via env; generated tool bodies resolve `woodshop` at RUNTIME (shutil.which → python -m woodshop), no baked-in local path; file-writing moved under main()/__main__ (was running at import); PyYAML declared under dev deps. - cutlist.py: drop the misleading "+10% waste" label — shopping already uses the kerf-aware CutPlan nesting. - Docs refreshed (README + CLAUDE): real test count, parametric joinery is modeled, new cutplan/prices/estimate/inventory/colors modules + GUI windows, portable tool regeneration. - tests: driver path discovery (PATH + module fallback), generated tool bodies compile and contain no hardcoded paths. 207 pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
01c4dee0bc
commit
970b88bc7b
20
CLAUDE.md
20
CLAUDE.md
|
|
@ -83,14 +83,26 @@ board-feet / 8' sticks).
|
|||
|
||||
Parts have full 3D orientation (`yaw_deg`/`tilt_deg`/`roll_deg`) so legs and
|
||||
uprights stand vertically. Parts can be referred to by id (`p1`) or by a name
|
||||
set with `rename`. The cut list (`cutlist.py`) reports board-feet and an 8'-stick
|
||||
shopping estimate.
|
||||
set with `rename`. Each part also carries `material` + `finish` (raw/sanded/
|
||||
clear/stain/paint) + `finish_color`, which drive the viewer color and cost.
|
||||
|
||||
**Shop output & inventory subsystems** (all deterministic; AI only narrates):
|
||||
`cutplan.py` is the keystone — kerf-aware cutting-stock nesting into a JSON-
|
||||
serializable `CutPlan` (rough-vs-final sanding allowance, batch quantity, owned-
|
||||
offcut reuse, grouped by (stock, material)). `prices.py` = editable Kent-NB price
|
||||
book + material-aware cost; `estimate.py` = full quote (consumables + labor +
|
||||
suggested price); `inventory.py` = shop-wide event-sourced stock/offcut/build
|
||||
ledger; `instructions.py`/`jigs.py` = build steps + jig suggestions. The GUI
|
||||
**Cut List & BOM** window (`gui/bom_window.py`) and **Inventory** window
|
||||
(`gui/inventory_window.py`) render these. `cutlist.py` is a quick text summary.
|
||||
|
||||
### CmdForge tools (the documented command vocabulary)
|
||||
|
||||
`wood-place`, `wood-join`, `wood-sand`, `wood-delete`, `wood-undo` live in
|
||||
`~/.cmdforge/<name>/` and wrap the `woodshop` CLI. Regenerate them with
|
||||
`/tmp/gen_wood_tools.py` (kept in the repo plan) if their schemas change. The
|
||||
`python scripts/gen_wood_tools.py` if their schemas change (set `CMDFORGE_PY` to
|
||||
point at your cmdforge interpreter; needs PyYAML). The generated wrappers resolve
|
||||
`woodshop` at runtime (PATH, else `python -m woodshop`), so they're portable. The
|
||||
arg descriptions ARE the LLM's documentation, so keep them clear.
|
||||
|
||||
### Setup
|
||||
|
|
@ -98,7 +110,7 @@ arg descriptions ARE the LLM's documentation, so keep them clear.
|
|||
```bash
|
||||
python3 -m venv .venv && source .venv/bin/activate
|
||||
pip install -e ".[viewer,dev]" # viewer extra pulls build123d + pyvista
|
||||
pytest # 25 tests
|
||||
pytest # 200+ tests
|
||||
```
|
||||
|
||||
### Known limitations / next steps
|
||||
|
|
|
|||
25
README.md
25
README.md
|
|
@ -96,28 +96,37 @@ The active scene lives at `$WOODSHOP_SCENE` or
|
|||
## Development
|
||||
|
||||
```bash
|
||||
pytest # 41 tests
|
||||
pytest # 200+ tests
|
||||
```
|
||||
|
||||
Key modules:
|
||||
|
||||
| Module | Role |
|
||||
|--------|------|
|
||||
| `scene.py` | Part/Joint/Scene model, operations, undo, persistence |
|
||||
| `lumber.py` | nominal → actual dimensional lumber table |
|
||||
| `scene.py` | Part/Joint/Connection/Feature/Scene model, ops, undo, persistence |
|
||||
| `lumber.py` | nominal → actual dimensions, material colors/defaults |
|
||||
| `colors.py` | color name → hex + lightness blends for the viewer |
|
||||
| `units.py` | parse "6 ft" / "3 ft 6 in" / "-2 ft" → inches |
|
||||
| `cli.py` | the `woodshop` command |
|
||||
| `geometry.py` | build123d solids + STL/STEP export |
|
||||
| `cutlist.py` | cut list, board-feet, shopping estimate |
|
||||
| `geometry.py` | build123d solids (incl. joinery booleans) + STL/STEP export |
|
||||
| `cutlist.py` | quick cut list / board-feet / shopping summary |
|
||||
| `cutplan.py` | the deterministic keystone: kerf-aware nesting, rough/final, batch, offcut reuse |
|
||||
| `prices.py` | editable price book (Kent NB) + material-aware cost estimate |
|
||||
| `estimate.py` | project quote: consumables + labor + suggested selling price |
|
||||
| `inventory.py` | shop-wide event-sourced stock / offcut / build ledger |
|
||||
| `instructions.py` / `jigs.py` | deterministic build steps & jig suggestions |
|
||||
| `viewer.py` | live pyvista 3D viewport (`woodshop-view`) |
|
||||
| `driver.py` | conversational loop (`woodshop-talk`) |
|
||||
| `gui/` | the unified PySide6 studio (`woodshop-gui`), incl. the Cut List & BOM window |
|
||||
| `scripts/gen_wood_tools.py` | (re)generate the `wood-*` CmdForge tools |
|
||||
|
||||
### Known limitations
|
||||
|
||||
- Joins are flush butt joints: B's end sits against A's face and B aligns to
|
||||
A's reference corner (tops level + one side flush), so mixed-size boards line
|
||||
up. Joinery *cuts* (mortise/tenon, lap, pocket holes) aren't modeled yet.
|
||||
- **Joinery** is parametric (tenon/mortise/dado/rabbet/hole/slot/chamfer as
|
||||
build123d boolean ops, with connections/assemblies); what's *not* modeled is
|
||||
joinery-fit compensation for material lost to sanding, and lap/pocket-hole
|
||||
presets. Boards still attach as flush butt joints unless you add features.
|
||||
- Render is flat colors per material/finish — no image textures (wood grain) yet.
|
||||
- Command interpretation latency is ~7–13s per utterance (one `claude -p` call).
|
||||
|
||||
## License
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ gui = [
|
|||
dev = [
|
||||
"pytest>=7.0",
|
||||
"pytest-cov>=4.0",
|
||||
"PyYAML>=6.0", # scripts/gen_wood_tools.py emits CmdForge tool YAML
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
|
|
|
|||
|
|
@ -7,20 +7,31 @@ Run this after changing the woodshop CLI to refresh the tools:
|
|||
python scripts/gen_wood_tools.py
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
CMDFORGE_PY = "/home/rob/.local/share/pipx/venvs/cmdforge/bin/python"
|
||||
# The cmdforge interpreter for the generated wrappers — overridable via env so
|
||||
# this script isn't pinned to one machine's pipx layout.
|
||||
CMDFORGE_PY = os.environ.get(
|
||||
"CMDFORGE_PY",
|
||||
str(Path.home() / ".local" / "share" / "pipx" / "venvs" / "cmdforge" / "bin" / "python"))
|
||||
CMDFORGE_DIR = Path.home() / ".cmdforge"
|
||||
BIN_DIR = Path.home() / ".local" / "bin"
|
||||
WS = 'ws = os.path.expanduser("~/PycharmProjects/woodshop/.venv/bin/woodshop")'
|
||||
|
||||
# Resolved at TOOL RUNTIME (not generation time) so the tools are portable:
|
||||
# the installed `woodshop` entry point if on PATH, else `python -m woodshop`.
|
||||
WS = ("_ws = shutil.which('woodshop')\n"
|
||||
"ws = [_ws] if _ws else [sys.executable, '-m', 'woodshop']")
|
||||
|
||||
|
||||
def code(body: str) -> str:
|
||||
"""Wrap a command-building body that sets `cmd`, then runs it."""
|
||||
return (f"import subprocess, os\n{WS}\n{body}\n"
|
||||
"""Wrap a command-building body that sets `cmd`, then runs it. `ws` is the
|
||||
resolved woodshop command PREFIX (a list)."""
|
||||
return (f"import subprocess, shutil, sys\n{WS}\n{body}\n"
|
||||
"r = subprocess.run(cmd, capture_output=True, text=True)\n"
|
||||
"out = (r.stdout + r.stderr).strip()\n")
|
||||
|
||||
|
|
@ -34,7 +45,7 @@ TOOLS = {
|
|||
{"flag": "--width", "variable": "width", "default": "", "description": "Panel width (REQUIRED for plywood, ignored for lumber), e.g. '24 in'"},
|
||||
],
|
||||
"code": code(
|
||||
'cmd = [ws, "place", stock, length]\n'
|
||||
'cmd = ws + ["place", stock, length]\n'
|
||||
'if width != "": cmd += ["--width", str(width)]'
|
||||
),
|
||||
},
|
||||
|
|
@ -48,7 +59,7 @@ TOOLS = {
|
|||
{"flag": "--anchor", "variable": "anchor", "default": "end_b", "description": "Measure offset from 'end' (far end) or 'start'"},
|
||||
],
|
||||
"code": code(
|
||||
'cmd = [ws, "join", part_b]\n'
|
||||
'cmd = ws + ["join", part_b]\n'
|
||||
'if to: cmd += ["--to", to]\n'
|
||||
'if angle: cmd += ["--angle", str(angle)]\n'
|
||||
'if offset: cmd += ["--offset", offset]\n'
|
||||
|
|
@ -61,14 +72,14 @@ TOOLS = {
|
|||
{"flag": "--part", "variable": "part", "default": "", "description": "Board id or name. Omit for the most recent board."},
|
||||
{"flag": "--tilt", "variable": "tilt", "default": "90", "description": "Tilt degrees, 90 = straight up (default 90)"},
|
||||
],
|
||||
"code": code('cmd = [ws, "stand"] + ([part] if part else []) + ["--tilt", str(tilt)]'),
|
||||
"code": code('cmd = ws + ["stand"] + ([part] if part else []) + ["--tilt", str(tilt)]'),
|
||||
},
|
||||
"wood-lay": {
|
||||
"description": "Lay a board flat / horizontal. Use for 'lay it down', 'make it flat', 'horizontal'.",
|
||||
"arguments": [
|
||||
{"flag": "--part", "variable": "part", "default": "", "description": "Board id or name. Omit for the most recent board."},
|
||||
],
|
||||
"code": code('cmd = [ws, "lay"] + ([part] if part else [])'),
|
||||
"code": code('cmd = ws + ["lay"] + ([part] if part else [])'),
|
||||
},
|
||||
"wood-rotate": {
|
||||
"description": "Rotate / re-orient a board. Use for 'rotate', 'turn', 'angle it'.",
|
||||
|
|
@ -79,7 +90,7 @@ TOOLS = {
|
|||
{"flag": "--roll", "variable": "roll", "default": "", "description": "Rotation about the board's own length, degrees"},
|
||||
],
|
||||
"code": code(
|
||||
'cmd = [ws, "rotate"] + ([part] if part else [])\n'
|
||||
'cmd = ws + ["rotate"] + ([part] if part else [])\n'
|
||||
'if yaw != "": cmd += ["--yaw", str(yaw)]\n'
|
||||
'if tilt != "": cmd += ["--tilt", str(tilt)]\n'
|
||||
'if roll != "": cmd += ["--roll", str(roll)]'
|
||||
|
|
@ -94,7 +105,7 @@ TOOLS = {
|
|||
{"flag": "--dz", "variable": "dz", "default": "", "description": "Z offset (up/down)"},
|
||||
],
|
||||
"code": code(
|
||||
'cmd = [ws, "move"] + ([part] if part else [])\n'
|
||||
'cmd = ws + ["move"] + ([part] if part else [])\n'
|
||||
'if dx != "": cmd += ["--dx", dx]\n'
|
||||
'if dy != "": cmd += ["--dy", dy]\n'
|
||||
'if dz != "": cmd += ["--dz", dz]'
|
||||
|
|
@ -106,7 +117,7 @@ TOOLS = {
|
|||
{"flag": "--length", "variable": "length", "description": "New length with units, e.g. '4 ft'"},
|
||||
{"flag": "--part", "variable": "part", "default": "", "description": "Board id or name (default: most recent)"},
|
||||
],
|
||||
"code": code('cmd = [ws, "trim", length] + (["--part", part] if part else [])'),
|
||||
"code": code('cmd = ws + ["trim", length] + (["--part", part] if part else [])'),
|
||||
},
|
||||
"wood-copy": {
|
||||
"description": "Duplicate a board, offset by dx/dy/dz. Use for 'copy', 'duplicate', 'another one like that'.",
|
||||
|
|
@ -117,7 +128,7 @@ TOOLS = {
|
|||
{"flag": "--dz", "variable": "dz", "default": "", "description": "Z offset"},
|
||||
],
|
||||
"code": code(
|
||||
'cmd = [ws, "copy"] + ([part] if part else [])\n'
|
||||
'cmd = ws + ["copy"] + ([part] if part else [])\n'
|
||||
'if dx != "": cmd += ["--dx", dx]\n'
|
||||
'if dy != "": cmd += ["--dy", dy]\n'
|
||||
'if dz != "": cmd += ["--dz", dz]'
|
||||
|
|
@ -129,43 +140,43 @@ TOOLS = {
|
|||
{"flag": "--name", "variable": "name", "description": "The name, e.g. 'front-left leg'"},
|
||||
{"flag": "--part", "variable": "part", "default": "", "description": "Board id (default: most recent)"},
|
||||
],
|
||||
"code": code('cmd = [ws, "rename", name] + (["--part", part] if part else [])'),
|
||||
"code": code('cmd = ws + ["rename", name] + (["--part", part] if part else [])'),
|
||||
},
|
||||
"wood-sand": {
|
||||
"description": "Sand a board smooth. Use for 'sand', 'smooth', 'finish'.",
|
||||
"arguments": [
|
||||
{"flag": "--part", "variable": "part", "default": "", "description": "Board id or name. Omit to sand the most recent board ('it')."},
|
||||
],
|
||||
"code": code('cmd = [ws, "sand"] + ([part] if part else [])'),
|
||||
"code": code('cmd = ws + ["sand"] + ([part] if part else [])'),
|
||||
},
|
||||
"wood-delete": {
|
||||
"description": "Remove a board. Use for 'delete', 'remove', 'get rid of', 'scrap'.",
|
||||
"arguments": [
|
||||
{"flag": "--part", "variable": "part", "default": "", "description": "Board id or name (default: most recent)"},
|
||||
],
|
||||
"code": code('cmd = [ws, "delete"] + ([part] if part else [])'),
|
||||
"code": code('cmd = ws + ["delete"] + ([part] if part else [])'),
|
||||
},
|
||||
"wood-select": {
|
||||
"description": "Set the current selection — the board future commands like 'rotate that' or 'delete it' act on. Use for 'select', 'pick', 'grab the', 'use the'.",
|
||||
"arguments": [
|
||||
{"flag": "--part", "variable": "part", "description": "Board id or name to select, e.g. p3 or 'front-left leg'"},
|
||||
],
|
||||
"code": code('cmd = [ws, "select", part]'),
|
||||
"code": code('cmd = ws + ["select", part]'),
|
||||
},
|
||||
"wood-undo": {
|
||||
"description": "Undo the last operation. Use for 'undo', 'never mind', 'take that back', 'go back'.",
|
||||
"arguments": [],
|
||||
"code": code('cmd = [ws, "undo"]'),
|
||||
"code": code('cmd = ws + ["undo"]'),
|
||||
},
|
||||
"wood-redo": {
|
||||
"description": "Redo the last undone operation. Use for 'redo', 'put it back', 'never mind that undo'.",
|
||||
"arguments": [],
|
||||
"code": code('cmd = [ws, "redo"]'),
|
||||
"code": code('cmd = ws + ["redo"]'),
|
||||
},
|
||||
"wood-clear": {
|
||||
"description": "Clear the whole scene and start over. Use for 'clear', 'start over', 'reset', 'new project'.",
|
||||
"arguments": [],
|
||||
"code": code('cmd = [ws, "clear"]'),
|
||||
"code": code('cmd = ws + ["clear"]'),
|
||||
},
|
||||
"wood-feature": {
|
||||
"description": "Add a joinery feature to a board: tenon (male tongue), mortise (pocket), hole, slot, dado, or rabbet. Use for 'add a tenon', 'cut a mortise', 'drill a hole', 'cut a slot'.",
|
||||
|
|
@ -182,7 +193,7 @@ TOOLS = {
|
|||
{"flag": "--rotation", "variable": "rotation", "default": "", "description": "Rotate the feature about its face normal, degrees"},
|
||||
],
|
||||
"code": code(
|
||||
'cmd = [ws, "feature", kind]\n'
|
||||
'cmd = ws + ["feature", kind]\n'
|
||||
'if part: cmd += ["--part", part]\n'
|
||||
'if face: cmd += ["--face", face]\n'
|
||||
'for flag, val in [("--along", along), ("--across", across), ("--width", width),\n'
|
||||
|
|
@ -197,75 +208,81 @@ TOOLS = {
|
|||
{"flag": "--anchor", "variable": "anchor", "description": "Feature id that stays put, e.g. f1"},
|
||||
{"flag": "--moving", "variable": "moving", "description": "Feature id whose board moves to mate, e.g. f2"},
|
||||
],
|
||||
"code": code('cmd = [ws, "connect", anchor, moving]'),
|
||||
"code": code('cmd = ws + ["connect", anchor, moving]'),
|
||||
},
|
||||
"wood-explode": {
|
||||
"description": "Back connected boards off along their joint axes for an exploded view. Use for 'explode', 'back off the connections', 'show it pre-assembled'.",
|
||||
"arguments": [
|
||||
{"flag": "--distance", "variable": "distance", "description": "How far to separate, e.g. '3 in'"},
|
||||
],
|
||||
"code": code('cmd = [ws, "explode", distance]'),
|
||||
"code": code('cmd = ws + ["explode", distance]'),
|
||||
},
|
||||
"wood-assemble": {
|
||||
"description": "Re-seat all connections (reverse an explode / re-fit the joints). Use for 'assemble', 'put it back together', 're-fit', 'close it up'.",
|
||||
"arguments": [],
|
||||
"code": code('cmd = [ws, "assemble"]'),
|
||||
"code": code('cmd = ws + ["assemble"]'),
|
||||
},
|
||||
"wood-disconnect": {
|
||||
"description": "Break a connection so the pieces become independent (they stay where they are). Use for 'disconnect', 'break the connection', 'separate them'.",
|
||||
"arguments": [
|
||||
{"flag": "--connection", "variable": "connection", "description": "Connection id, e.g. c1"},
|
||||
],
|
||||
"code": code('cmd = [ws, "disconnect", connection]'),
|
||||
"code": code('cmd = ws + ["disconnect", connection]'),
|
||||
},
|
||||
"wood-feature-delete": {
|
||||
"description": "Remove a joinery feature by its id. Use for 'delete the mortise', 'remove that hole'.",
|
||||
"arguments": [
|
||||
{"flag": "--fid", "variable": "fid", "description": "Feature id, e.g. f1"},
|
||||
],
|
||||
"code": code('cmd = [ws, "feature-delete", fid]'),
|
||||
"code": code('cmd = ws + ["feature-delete", fid]'),
|
||||
},
|
||||
"wood-cutlist": {
|
||||
"description": "Report the cut list / bill of materials: every board, board-feet, and how much lumber to buy. Use for 'cut list', 'what do I need to buy', 'bill of materials', 'how much wood'.",
|
||||
"arguments": [],
|
||||
"code": code('cmd = [ws, "cutlist"]'),
|
||||
"code": code('cmd = ws + ["cutlist"]'),
|
||||
},
|
||||
"wood-save": {
|
||||
"description": "Save the current design as a named project. Use for 'save this as', 'save the project', 'remember this design'.",
|
||||
"arguments": [
|
||||
{"flag": "--name", "variable": "name", "description": "Project name, e.g. 'coffee table'"},
|
||||
],
|
||||
"code": code('cmd = [ws, "save", name]'),
|
||||
"code": code('cmd = ws + ["save", name]'),
|
||||
},
|
||||
"wood-open": {
|
||||
"description": "Open a previously saved project (replaces the current scene). Use for 'open', 'load the', 'go back to my'.",
|
||||
"arguments": [
|
||||
{"flag": "--name", "variable": "name", "description": "Name of the project to open"},
|
||||
],
|
||||
"code": code('cmd = [ws, "open", name]'),
|
||||
"code": code('cmd = ws + ["open", name]'),
|
||||
},
|
||||
"wood-projects": {
|
||||
"description": "List saved projects. Use for 'what projects do I have', 'list my designs'.",
|
||||
"arguments": [],
|
||||
"code": code('cmd = [ws, "projects"]'),
|
||||
"code": code('cmd = ws + ["projects"]'),
|
||||
},
|
||||
}
|
||||
|
||||
WRAPPER = ('#!/bin/bash\n# CmdForge wrapper for \'{name}\'\n# Auto-generated - do not edit\n'
|
||||
'exec "{py}" -m cmdforge.runner "{name}" "$@"\n')
|
||||
|
||||
for name, spec in TOOLS.items():
|
||||
tool_dir = CMDFORGE_DIR / name
|
||||
tool_dir.mkdir(parents=True, exist_ok=True)
|
||||
config = {
|
||||
"name": name, "description": spec["description"], "category": "Other",
|
||||
"version": "0.2.0", "arguments": spec["arguments"],
|
||||
"steps": [{"type": "code", "code": spec["code"], "output_var": "out"}],
|
||||
"output": "{out}",
|
||||
}
|
||||
(tool_dir / "config.yaml").write_text(yaml.safe_dump(config, sort_keys=False))
|
||||
wrapper = BIN_DIR / name
|
||||
wrapper.write_text(WRAPPER.format(name=name, py=CMDFORGE_PY))
|
||||
wrapper.chmod(wrapper.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
||||
print(f"created {name}")
|
||||
print(f"\n{len(TOOLS)} wood-* tools written to {CMDFORGE_DIR} and {BIN_DIR}")
|
||||
|
||||
def main() -> None:
|
||||
for name, spec in TOOLS.items():
|
||||
tool_dir = CMDFORGE_DIR / name
|
||||
tool_dir.mkdir(parents=True, exist_ok=True)
|
||||
config = {
|
||||
"name": name, "description": spec["description"], "category": "Other",
|
||||
"version": "0.2.0", "arguments": spec["arguments"],
|
||||
"steps": [{"type": "code", "code": spec["code"], "output_var": "out"}],
|
||||
"output": "{out}",
|
||||
}
|
||||
(tool_dir / "config.yaml").write_text(yaml.safe_dump(config, sort_keys=False))
|
||||
wrapper = BIN_DIR / name
|
||||
wrapper.write_text(WRAPPER.format(name=name, py=CMDFORGE_PY))
|
||||
wrapper.chmod(wrapper.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
||||
print(f"created {name}")
|
||||
print(f"\n{len(TOOLS)} wood-* tools written to {CMDFORGE_DIR} and {BIN_DIR}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ def format_cutlist(scene: Scene) -> str:
|
|||
lines.append(" Total: " + tot)
|
||||
if any(cut_length(p) > p.length_in for p in scene.parts):
|
||||
lines.append(" (cut lengths include protruding tenons)")
|
||||
lines.append("SHOPPING (8' sticks / 4×8 sheets, +10% waste)")
|
||||
lines.append("SHOPPING (8' sticks / 4×8 sheets, kerf-aware nesting)")
|
||||
for stock, qty in shopping(scene).items():
|
||||
unit = "sheet(s)" if stock.startswith("ply-") else "stick(s)"
|
||||
lines.append(f" {qty} × {stock} {unit}")
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ from __future__ import annotations
|
|||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
|
@ -39,9 +39,15 @@ def load_schemas() -> str:
|
|||
return _run(["pa-load-tools", "--filter", TOOL_FILTER, "--format", "anthropic"])
|
||||
|
||||
|
||||
def woodshop_cmd() -> list[str]:
|
||||
"""Resolve the `woodshop` CLI portably: the installed entry point if it's on
|
||||
PATH, else `python -m woodshop` with the current interpreter."""
|
||||
exe = shutil.which("woodshop")
|
||||
return [exe] if exe else [sys.executable, "-m", "woodshop"]
|
||||
|
||||
|
||||
def scene_summary() -> str:
|
||||
ws = os.path.expanduser("~/PycharmProjects/woodshop/.venv/bin/woodshop")
|
||||
return _run([ws, "status"]) or "empty"
|
||||
return _run(woodshop_cmd() + ["status"]) or "empty"
|
||||
|
||||
|
||||
SYSTEM = """You are WoodShop, a voice-driven woodworking assistant. Translate the \
|
||||
|
|
|
|||
|
|
@ -135,3 +135,14 @@ def test_handle_appends_to_history(monkeypatch):
|
|||
history = []
|
||||
driver.handle("hello", schemas="[]", voice=False, verbose=False, history=history)
|
||||
assert history == [("hello", "hi there")]
|
||||
|
||||
|
||||
def test_woodshop_cmd_prefers_path(monkeypatch):
|
||||
monkeypatch.setattr(driver.shutil, "which", lambda name: "/opt/bin/woodshop")
|
||||
assert driver.woodshop_cmd() == ["/opt/bin/woodshop"]
|
||||
|
||||
|
||||
def test_woodshop_cmd_falls_back_to_module(monkeypatch):
|
||||
monkeypatch.setattr(driver.shutil, "which", lambda name: None)
|
||||
cmd = driver.woodshop_cmd()
|
||||
assert cmd[1:] == ["-m", "woodshop"] and cmd[0] # python -m woodshop
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
"""Smoke tests for the wood-* tool generator (the fragile external layer).
|
||||
Loads the generator without running it (writes are under main()) and checks
|
||||
every generated tool body is valid, portable Python."""
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("yaml")
|
||||
|
||||
_SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "gen_wood_tools.py"
|
||||
|
||||
|
||||
def _load():
|
||||
spec = importlib.util.spec_from_file_location("gen_wood_tools", _SCRIPT)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod) # safe: file-writing is under main()
|
||||
return mod
|
||||
|
||||
|
||||
def test_every_tool_body_compiles_and_is_portable():
|
||||
mod = _load()
|
||||
assert mod.TOOLS and "wood-place" in mod.TOOLS
|
||||
for name, spec in mod.TOOLS.items():
|
||||
compile(spec["code"], name, "exec") # valid Python
|
||||
assert "ws + [" in spec["code"] or "ws +[" in spec["code"] # uses resolved prefix
|
||||
assert "PycharmProjects" not in spec["code"] # no hardcoded local path
|
||||
|
||||
|
||||
def test_generator_resolves_woodshop_at_runtime():
|
||||
mod = _load()
|
||||
assert "shutil.which('woodshop')" in mod.WS
|
||||
assert "PycharmProjects" not in mod.WS
|
||||
Loading…
Reference in New Issue