From 970b88bc7b62bbd46c99b5a081e5bcea3eaa0b43 Mon Sep 17 00:00:00 2001 From: rob Date: Sat, 30 May 2026 21:56:28 -0300 Subject: [PATCH] Portability + consistency polish (Codex review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CLAUDE.md | 20 ++++++-- README.md | 25 ++++++--- pyproject.toml | 1 + scripts/gen_wood_tools.py | 105 ++++++++++++++++++++++---------------- src/woodshop/cutlist.py | 2 +- src/woodshop/driver.py | 12 +++-- tests/test_driver.py | 11 ++++ tests/test_gen_tools.py | 33 ++++++++++++ 8 files changed, 149 insertions(+), 60 deletions(-) create mode 100644 tests/test_gen_tools.py diff --git a/CLAUDE.md b/CLAUDE.md index 3bc0901..a550bb0 100644 --- a/CLAUDE.md +++ b/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//` 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 diff --git a/README.md b/README.md index ce941a4..a330fba 100644 --- a/README.md +++ b/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 diff --git a/pyproject.toml b/pyproject.toml index 923e1ad..03774bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/scripts/gen_wood_tools.py b/scripts/gen_wood_tools.py index f97da55..e02e644 100644 --- a/scripts/gen_wood_tools.py +++ b/scripts/gen_wood_tools.py @@ -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() diff --git a/src/woodshop/cutlist.py b/src/woodshop/cutlist.py index ce89a52..7eb88c6 100644 --- a/src/woodshop/cutlist.py +++ b/src/woodshop/cutlist.py @@ -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}") diff --git a/src/woodshop/driver.py b/src/woodshop/driver.py index 16bd352..edbae37 100644 --- a/src/woodshop/driver.py +++ b/src/woodshop/driver.py @@ -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 \ diff --git a/tests/test_driver.py b/tests/test_driver.py index 5adb766..223d9f2 100644 --- a/tests/test_driver.py +++ b/tests/test_driver.py @@ -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 diff --git a/tests/test_gen_tools.py b/tests/test_gen_tools.py new file mode 100644 index 0000000..6e93181 --- /dev/null +++ b/tests/test_gen_tools.py @@ -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