From 61ec18febf7ec0a1a389411b7ae21329a09baeba Mon Sep 17 00:00:00 2001 From: sbussiso Date: Sun, 6 Sep 2026 20:27:10 -0700 Subject: [PATCH 1/3] feat: OrcaSlicer slicing + named printer presets - /slice picks Cura or OrcaSlicer (orca preferred when the local PrintMCP exposes it), with a --slicer cura|orca override. Presets resolve from the active printer preset -> /config orca_* keys -> defaults. - New printers table + /printer command (list/show/use/add/remove) to bundle cura_printer + orca machine/process/filament per machine. Applying via /printer use writes the active slicer settings; removing the active printer clears its settings so slicing falls back cleanly. A default ender-3-pro preset ships seeded. - Unified --adhesion maps to OrcaSlicer's real settings (brim_width / raft_layers / skirt_loops) instead of a nonexistent adhesion_type, and --infill/--temp/--bed/--supports map to Orca overrides. - app.py: parse command args with shlex (multi-word printer names work), dispatch /printer, banner shows slicer + local-vs-pypi PrintMCP source. - scanner.py ingests agent-invoked orca_slice_model results into the DB. - tests: 23 passing incl. preset CRUD/resolution/adhesion, scanner ingestion for both slicers, and a real cross-repo OrcaSlicer integration test. --- README.md | 64 +++++- pyproject.toml | 5 + src/printpal/app.py | 90 +++++++- src/printpal/commands/__init__.py | 5 +- src/printpal/commands/helpers.py | 1 + src/printpal/commands/printer.py | 191 +++++++++++++++++ src/printpal/commands/thing.py | 309 ++++++++++++++++++++------- src/printpal/completer.py | 1 + src/printpal/config.py | 19 ++ src/printpal/db.py | 147 ++++++++++++- src/printpal/scanner.py | 5 +- tests/test_printer_presets.py | 267 +++++++++++++++++++++++ tests/test_printmcp_command.py | 53 +++++ tests/test_scanner_orca.py | 102 +++++++++ tests/test_slice_orca_integration.py | 122 +++++++++++ 15 files changed, 1285 insertions(+), 96 deletions(-) create mode 100644 src/printpal/commands/printer.py create mode 100644 tests/test_printer_presets.py create mode 100644 tests/test_printmcp_command.py create mode 100644 tests/test_scanner_orca.py create mode 100644 tests/test_slice_orca_integration.py diff --git a/README.md b/README.md index 5a917a6..a7d4f42 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ It's like having a 3D printing expert sitting next to you, ready to find and pre - **Natural language search** — just describe what you want to print - **Self-contained storage** — all models and G-code stored as BLOBs in SQLite, no external files -- **Direct slicing** — `/slice ` runs CuraEngine instantly, zero AI tokens +- **Direct slicing** — `/slice ` runs CuraEngine or OrcaSlicer instantly, zero AI tokens - **Full print pipeline** — `/print ` preheats, uploads to OctoPrint, and starts - **Permission system** — control what the agent can do (Manual / Auto / Bypass modes) - **Session management** — save/load conversations with full memory and prompt history @@ -100,7 +100,7 @@ Settings can be stored in `.env` (for initial setup) or managed at runtime with | `thingiverse_token` | `THINGIVERSE_TOKEN` | Searching & downloading | Yes | | `octoprint_url` | `OCTOPRINT_URL` | Printing | No | | `octoprint_api_key` | `OCTOPRINT_API_KEY` | Printing | Yes | -| `cura_dir` | `PRINTMCP_CURA_DIR` | Slicing (auto-detected) | No | +| `cura_dir` | `PRINTMCP_CURA_DIR` | Slicing (Cura; auto-detected) | No | | `auto_save` | — | Optional convenience | No | ## 🎮 Commands @@ -127,16 +127,51 @@ Settings can be stored in `.env` (for initial setup) or managed at runtime with ### Slicing +`/slice` uses whichever slicer you have. **OrcaSlicer is preferred** when both are installed; force one with `--slicer cura|orca`. + | Command | Description | |---------|-------------| -| `/slice ` | Slice a model to G-code using CuraEngine | +| `/slice ` | Slice a model to G-code (auto-detected slicer) | +| `/slice --slicer cura` | Force CuraEngine for this slice | +| `/slice --slicer orca` | Force OrcaSlicer for this slice | | `/slice --layer-height 0.12` | Set layer height (0.05–0.6mm) | | `/slice --infill 40 --supports` | Set infill % and enable supports | -| `/slice --printer creality_ender3pro` | Set printer profile | +| `/slice --printer creality_ender3pro` | Set printer profile (Cura) | | `/slice --temp 210 --bed 65` | Set nozzle and bed temperatures | | `/slice --adhesion brim` | Set adhesion type (skirt/brim/raft/none) | -Short flags: `--lh`, `--inf`, `--sup`, `--ad`, `--t`, `--b`, `--p` +Short flags: `--lh`, `--inf`, `--sup`, `--ad`, `--t`, `--b`, `--p`, `--sl` + +For **OrcaSlicer**, PrintPal uses its 3-tier presets (machine / process / filament). Defaults assume an Ender-3 Pro with a 0.4 nozzle; point them at your machine and material via `/config`: + +```bash +/config set slicer orca # prefer OrcaSlicer always +/config set orca_machine "Creality Ender-3 Pro 0.4 nozzle" +/config set orca_process "0.20mm Standard @Creality Ender3 Pro 0.4" +/config set orca_filament "Creality Generic PLA" +``` + +Find preset names with the PrintMCP `orca_list_profiles` tool (or ask the agent: "list my OrcaSlicer machine presets"). + +### Printers + +A **printer preset** bundles everything the slicers need for one machine — the Cura definition id and the OrcaSlicer machine/process/filament presets — under one name. `/printer use ` applies the bundle so `/slice` (and the agent) slice for that printer. A default `ender-3-pro` preset ships with the app. + +| Command | Description | +|---------|-------------| +| `/printer` or `/printer list` | List presets (marks the active one with ●) | +| `/printer show [name]` | Show one preset's slicer settings (no arg = active) | +| `/printer use ` | Activate a preset — applies it to slicing | +| `/printer add [options]` | Save a preset. Options: `--cura --machine --process

--filament ` | +| `/printer remove ` | Delete a preset | + +Adding with no options pre-fills from the Ender-3 Pro defaults so you can tweak from a working baseline. Names with spaces work either quoted or unquoted (the command joins the remaining words): + +```bash +/printer add "Voron 2.4" --machine "Voron 2.4 0.4 nozzle" --process "0.20mm Standard" --filament "Generic PLA" +/printer use Voron 2.4 +/slice 1 +``` ### Printing @@ -228,6 +263,24 @@ PrintPal connects to **[PrintMCP](https://github.com/SourceBox-LLC/PrintMCP)** The AI agent (powered by [smolagents](https://github.com/huggingface/smolagents) + LiteLLM) uses these tools autonomously. Slash commands like `/slice` and `/print` call the tools directly — no AI tokens spent. +### Developing against a local PrintMCP + +By default PrintPal launches the published PrintMCP package (`uvx printmcp`). To develop both repos together — e.g. you're editing PrintMCP's tools and want PrintPal to use your local checkout — set `PRINTPAL_PRINTMCP_COMMAND` to the launch command before starting PrintPal: + +```bash +# Point PrintPal at your local PrintMCP checkout: +export PRINTPAL_PRINTMCP_COMMAND="uv run --directory /path/to/PrintMCP printmcp" +uv run printpal +``` + +or, from any venv that already has `printmcp` installed: + +```bash +export PRINTPAL_PRINTMCP_COMMAND="python -m printmcp" +``` + +The value is split with `shlex`, so quoted paths with spaces work. Which server you're talking to is shown in the startup banner — `pypi (uvx printmcp)` by default or `local ()` when overridden. Unset the variable to go back to the PyPI release. + ### Database Everything is stored in a single SQLite file at `~/.printpal/printpal.db`: @@ -251,6 +304,7 @@ Backups are stored at `~/.printpal/backups/`. | `THINGIVERSE_TOKEN is not set` | Run `/config set thingiverse_token ` or add to `.env` | | `OCTOPRINT_URL and OCTOPRINT_API_KEY not set` | Run `/config set octoprint_url ` and `/config set octoprint_api_key ` | | `CuraEngine not found` | Install Ultimaker Cura, or set `PRINTMCP_CURA_DIR` via `/config set cura_dir ` | +| `orca_slice_model tool not found` | Install [OrcaSlicer](https://www.orcaslicer.com/) (native or Flatpak), or slice with Cura: `/slice --slicer cura` | | Permission denied on every tool call | Switch to auto mode: `/mode auto` or press Shift+Tab | | `uv` not found | Install uv: `pip install uv` or see [uv docs](https://docs.astral.sh/uv/) | diff --git a/pyproject.toml b/pyproject.toml index 39f87c6..1b96009 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,3 +25,8 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/printpal"] + +[tool.pytest.ini_options] +markers = [ + "integration: end-to-end tests that require real tools (e.g. OrcaSlicer) or sibling checkouts; deselect with -m 'not integration'", +] diff --git a/src/printpal/app.py b/src/printpal/app.py index 30b1eea..99ee525 100644 --- a/src/printpal/app.py +++ b/src/printpal/app.py @@ -54,6 +54,7 @@ cmd_print_files, cmd_print_queue, cmd_mode, + cmd_printer, cmd_config, cmd_cost, cmd_logs, @@ -92,6 +93,48 @@ def _inject_db_settings() -> None: os.environ[env_key] = settings[db_key] +def _printmcp_server_params() -> tuple["StdioServerParameters", str]: + """Build the StdioServerParameters for the PrintMCP server. + + Default: ``uvx printmcp`` (the release published on PyPI). Set the + ``PRINTPAL_PRINTMCP_COMMAND`` environment variable to override the launch + command — e.g. to develop against a local PrintMCP checkout: + + export PRINTPAL_PRINTMCP_COMMAND="uv run --directory /path/to/PrintMCP printmcp" + + or, from a venv that has printmcp installed: + + export PRINTPAL_PRINTMCP_COMMAND="python -m printmcp" + + The value is split with shlex (so quoted args work). Returns the params + plus a short human-readable label of the source for the banner. + """ + import shlex + + override = os.environ.get("PRINTPAL_PRINTMCP_COMMAND", "").strip() + if override: + parts = shlex.split(override) + if not parts: + override = "" # treat whitespace-only as unset + else: + return ( + StdioServerParameters( + command=parts[0], + args=parts[1:], + env={**os.environ, "PYTHONUNBUFFERED": "1"}, + ), + f"local ({override})", + ) + return ( + StdioServerParameters( + command="uvx", + args=["printmcp"], + env={**os.environ, "PYTHONUNBUFFERED": "1"}, + ), + "pypi (uvx printmcp)", + ) + + def _sanitize_session_name(prompt: str) -> str: """Convert a user prompt to a valid session name.""" name = prompt.lower().strip() @@ -146,10 +189,15 @@ def _get_session_cost(agent, model_id: str) -> float: return 0.0 -def _print_banner(tool_count: int = 0) -> None: +def _print_banner(tool_count: int = 0, printmcp_source: str = "", slicer: str = "") -> None: tip = random.choice(TIPS) if tool_count > 0: - mcp_status = Text(f"PrintMCP: connected ({tool_count} tools)\n", style="green") + mcp_status = Text(f"PrintMCP: connected ({tool_count} tools)", style="green") + if printmcp_source: + mcp_status.append(f" [{printmcp_source}]", style="dim") + if slicer: + mcp_status.append(f"\nslicer: {slicer}", style="dim") + mcp_status.append("\n") else: mcp_status = Text("PrintMCP: not connected\n", style="bold red") @@ -160,12 +208,25 @@ def _print_banner(tool_count: int = 0) -> None: logo_text + mcp_status + Text(f"model: {MODEL_ID}\n", style="dim") - + Text(f"\u2605 {tip}", style=f"italic {ACCENT}"), + + Text(f"★ {tip}", style=f"italic {ACCENT}"), border_style=ACCENT, ) ) +def _detect_slicer_label(tool_names: set[str]) -> str: + """Human label for which slicer backend(s) the connected PrintMCP exposed.""" + has_orca = "orca_slice_model" in tool_names + has_cura = "cura_slice_model" in tool_names + if has_orca and has_cura: + return "OrcaSlicer + Cura" + if has_orca: + return "OrcaSlicer" + if has_cura: + return "Cura" + return "" + + def _print_status_line( tool_count: int = 0, perm_state: PermissionState | None = None, @@ -335,11 +396,7 @@ def main(): except Exception: pass - server_params = StdioServerParameters( - command="uvx", - args=["printmcp"], - env={**os.environ, "PYTHONUNBUFFERED": "1"}, - ) + server_params, printmcp_source = _printmcp_server_params() try: with MCPClient(server_params, structured_output=True) as tools: @@ -357,7 +414,8 @@ def main(): history = InMemoryHistory() if _HAS_PT else None tool_count = len(tools) current_session_name = None - _print_banner(tool_count) + slicer_label = _detect_slicer_label({t.name for t in tools}) + _print_banner(tool_count, printmcp_source, slicer_label) while True: try: @@ -380,7 +438,13 @@ def main(): continue if user_input.startswith("/"): - parts = user_input.split() + try: + import shlex + + parts = shlex.split(user_input) + except ValueError: + # Unbalanced quote — fall back to plain whitespace split. + parts = user_input.split() cmd = parts[0].lower() args = parts[1:] @@ -480,7 +544,7 @@ def main(): if cmd == "/redraw": console.clear() - _print_banner(tool_count) + _print_banner(tool_count, printmcp_source, slicer_label) continue if cmd == "/self-destruct": @@ -496,6 +560,10 @@ def main(): cmd_slice(args, tools) continue + if cmd == "/printer": + cmd_printer(args) + continue + if cmd == "/print": if not args: cmd_print(args, tools) diff --git a/src/printpal/commands/__init__.py b/src/printpal/commands/__init__.py index fc86513..0734460 100644 --- a/src/printpal/commands/__init__.py +++ b/src/printpal/commands/__init__.py @@ -11,7 +11,8 @@ "/load": "Load a saved session. Usage: /load ", "/sessions": "List all saved sessions.", "/thing": "Manage downloaded models. Usage: /thing [id|export [dest]|delete ]", - "/slice": "Slice a model to G-code. Usage: /slice [flags]", + "/slice": "Slice a model to G-code (Cura or OrcaSlicer). Usage: /slice [flags] [--slicer cura|orca]", + "/printer": "Manage printer presets. Usage: /printer [list|show |use |add |remove ]", "/print": "Full print pipeline: preheat, upload, start. Usage: /print [--no-preheat]", "/print status": "Live printer status, temps, and job progress. Ctrl+C to stop.", "/print pause": "Pause the active print job.", @@ -46,6 +47,7 @@ ) from .session import cmd_save, cmd_load, cmd_sessions # noqa: E402 from .thing import cmd_thing, cmd_thing_dispatch, cmd_slice # noqa: E402 +from .printer import cmd_printer # noqa: E402 from .print import ( # noqa: E402 cmd_print, cmd_print_status, @@ -75,6 +77,7 @@ "cmd_thing", "cmd_thing_dispatch", "cmd_slice", + "cmd_printer", "cmd_print", "cmd_print_status", "cmd_print_pause", diff --git a/src/printpal/commands/helpers.py b/src/printpal/commands/helpers.py index 8d846f4..77942e8 100644 --- a/src/printpal/commands/helpers.py +++ b/src/printpal/commands/helpers.py @@ -84,6 +84,7 @@ def prompt_save_if_dirty( "--temp": ("--t", "material_print_temperature", int, 200, (150, 300)), "--bed": ("--b", "material_bed_temperature", int, 60, (0, 120)), "--printer": ("--p", "printer", str, "creality_ender3pro", None), + "--slicer": ("--sl", "_slicer", str, None, None), } diff --git a/src/printpal/commands/printer.py b/src/printpal/commands/printer.py new file mode 100644 index 0000000..3078d1c --- /dev/null +++ b/src/printpal/commands/printer.py @@ -0,0 +1,191 @@ +"""Printer preset commands: /printer [list|use|show|add|remove]. + +Printers are named bundles of slicer settings (Cura printer id + OrcaSlicer +machine/process/filament presets). `/printer use ` applies the bundle into +the active settings so `/slice` and the agent slice for that printer. +""" + +from __future__ import annotations + +from rich import box +from rich.panel import Panel +from rich.table import Table +from rich.text import Text + +from .. import db +from ..config import ORCA_DEFAULTS +from ..ui import ACCENT, console, prompt_yes_no + +_USAGE = ( + "Usage: /printer [list|show |use |add [opts]|remove ]\n" + " add options: --cura --machine " + "--process --filament " +) + + +def cmd_printer(args: list[str]) -> None: + """Dispatch /printer sub-commands.""" + if not args or args[0] == "list": + _list() + elif args[0] == "use": + _use(args[1:]) + elif args[0] == "show": + _show(args[1:]) + elif args[0] == "add": + _add(args[1:]) + elif args[0] == "remove" or args[0] == "delete": + _remove(args[1:]) + else: + console.print(Text(_USAGE, style="dim")) + + +def _list() -> None: + printers = db.list_printers() + active = db.get_active_printer() + active_name = active["name"] if active else None + + if not printers: + console.print(Text("No printer presets. Add one with /printer add.", style="dim")) + return + + table = Table( + show_header=True, header_style="bold", box=box.HORIZONTALS, border_style=ACCENT + ) + table.add_column("", width=2) # active marker + table.add_column("Name", style=f"bold {ACCENT}", min_width=14) + table.add_column("Cura printer", style="dim") + table.add_column("Orca machine", style="dim") + for p in printers: + marker = "●" if p["name"] == active_name else "" + table.add_row(marker, p["name"], p["cura_printer"] or "—", p["orca_machine"] or "—") + console.print(table) + if active_name: + console.print(Text(f" active: {active_name}", style="dim")) + else: + console.print(Text(" none active — /printer use to select one", style="dim")) + + +def _name_from_args(args: list[str]) -> str: + """Printer names from the REPL may be unquoted multi-word (e.g. + `/printer use voron 2.4`). Join args with spaces; shlex already stripped + any quotes the user typed, so both `/printer use "voron 2.4"` and the bare + form land here the same way.""" + return " ".join(args).strip() + + +def _show(args: list[str]) -> None: + name = _name_from_args(args) + if not name: + active = db.get_active_printer() + if active is None: + console.print(Text("No active printer. /printer use to select one.", style="dim")) + return + p = active + else: + p = db.get_printer(name) + if p is None: + console.print(Text(f"No printer preset '{name}'.", style="bold red")) + return + + active = db.get_active_printer() + is_active = active is not None and active["name"].lower() == p["name"].lower() + lines = [ + Text(f"Name: {p['name']}{' (active)' if is_active else ''}", style=f"bold {ACCENT}"), + Text(f"Cura printer: {p['cura_printer'] or '—'}"), + Text(f"Orca machine: {p['orca_machine'] or '—'}"), + Text(f"Orca process: {p['orca_process'] or '—'}"), + Text(f"Orca filament:{(' ' + p['orca_filament']) if p['orca_filament'] else ' —'}"), + ] + console.print( + Panel(Text("\n").join(lines), title=Text("Printer", style="bold"), border_style=ACCENT) + ) + + +def _use(args: list[str]) -> None: + name = _name_from_args(args) + if not name: + console.print(Text("Usage: /printer use ", style="dim")) + return + applied = db.use_printer(name) + if applied is None: + near = ", ".join(p["name"] for p in db.list_printers()) or "(none)" + console.print( + Text(f"No printer preset '{name}'. Available: {near}", style="bold red") + ) + return + console.print( + Text(f"Active printer: {applied['name']}", style=f"bold {ACCENT}") + ) + console.print(Text(f" Orca machine: {applied['orca_machine'] or '—'}", style="dim")) + console.print(Text(f" Orca process: {applied['orca_process'] or '—'}", style="dim")) + console.print(Text(f" Orca filament: {applied['orca_filament'] or '—'}", style="dim")) + console.print(Text(f" Cura printer: {applied['cura_printer'] or '—'}", style="dim")) + + +def _parse_add_opts(args: list[str]) -> tuple[str, dict[str, str]]: + """Parse `/printer add --cura X --machine Y --process Z --filament W`.""" + if not args: + raise ValueError("Usage: /printer add [options]") + name = args[0] + opts = {"cura": "", "machine": "", "process": "", "filament": ""} + flagmap = {"--cura": "cura", "--machine": "machine", "--process": "process", "--filament": "filament"} + i = 1 + while i < len(args): + flag = args[i] + if flag not in flagmap: + raise ValueError(f"Unknown option: {flag}") + if i + 1 >= len(args): + raise ValueError(f"Missing value for {flag}") + opts[flagmap[flag]] = args[i + 1] + i += 2 + return name, opts + + +def _add(args: list[str]) -> None: + try: + name, opts = _parse_add_opts(args) + except ValueError as e: + console.print(Text(str(e), style="bold red")) + console.print(Text(_USAGE, style="dim")) + return + + # Pre-fill from the Ender-3 Pro defaults so a bare `/printer add voron` + # still yields a working (if generic) starting point; the user refines it. + db.upsert_printer( + name, + cura_printer=opts["cura"] or "creality_ender3pro", + orca_machine=opts["machine"] or ORCA_DEFAULTS["orca_machine"], + orca_process=opts["process"] or ORCA_DEFAULTS["orca_process"], + orca_filament=opts["filament"] or ORCA_DEFAULTS["orca_filament"], + ) + console.print(Text(f"Saved printer preset '{name}'.", style=f"bold {ACCENT}")) + console.print(Text(f" Activate it with: /printer use {name}", style="dim")) + + +def _remove(args: list[str]) -> None: + name = _name_from_args(args) + if not name: + console.print(Text("Usage: /printer remove ", style="dim")) + return + if db.get_printer(name) is None: + console.print(Text(f"No printer preset '{name}'.", style="bold red")) + return + if not prompt_yes_no(f"Remove printer preset '{name}'? [y/N] "): + console.print(Text("Cancelled.", style="dim")) + return + # Capture whether this preset is active BEFORE deleting it (after delete, + # get_active_printer() can no longer resolve the name back to a row). + active_before = db.get_active_printer() + was_active = active_before is not None and active_before["name"].lower() == name.lower() + + if db.delete_printer(name): + # If it was active, clear the active pointer AND the settings it had + # applied so slicing falls back to defaults/remaining printers instead + # of a phantom preset. + if was_active: + db.delete_setting("active_printer") + for k in ("orca_machine", "orca_process", "orca_filament", "cura_printer"): + db.delete_setting(k) + console.print(Text(f"Removed printer preset '{name}'.", style=f"bold {ACCENT}")) + else: + console.print(Text(f"No printer preset '{name}'.", style="bold red")) diff --git a/src/printpal/commands/thing.py b/src/printpal/commands/thing.py index 9db4d36..7a266f3 100644 --- a/src/printpal/commands/thing.py +++ b/src/printpal/commands/thing.py @@ -11,8 +11,9 @@ from rich.table import Table from rich.text import Text -from ..ui import console, ACCENT, format_size from .. import db +from ..config import ORCA_DEFAULTS, ORCA_FLAG_OVERRIDES +from ..ui import ACCENT, console, format_size from .helpers import find_tool, parse_slice_flags @@ -144,7 +145,8 @@ def cmd_slice(args: list[str], tools: list) -> None: if not args: console.print( Text( - "Usage: /slice [flags] (e.g. /slice 1 --layer-height 0.12 --supports)", + "Usage: /slice [flags] (e.g. /slice 1 --layer-height 0.12 --supports)\n" + " Slicers: auto-detects Cura or OrcaSlicer. Force one with --slicer cura|orca.", style="dim", ) ) @@ -162,6 +164,14 @@ def cmd_slice(args: list[str], tools: list) -> None: console.print(Text(str(e), style="bold red")) return + # Pop the control key (not a tool kwarg). --slicer cura|orca forces a backend. + slicer_pref = (flags.pop("_slicer", None) or db.get_setting("slicer") or ORCA_DEFAULTS["slicer"]).lower() + if slicer_pref not in ("auto", "cura", "orca"): + console.print( + Text(f"Unknown slicer '{slicer_pref}' (use cura or orca).", style="bold red") + ) + return + t = db.get_thing(thing_id) if t is None: console.print(Text(f"No thing with ID {thing_id}.", style="bold red")) @@ -182,18 +192,127 @@ def cmd_slice(args: list[str], tools: list) -> None: ) return - slice_tool = find_tool(tools, "cura_slice_model") - if slice_tool is None: - console.print( - Text( - "cura_slice_model tool not found. Is PrintMCP running?", - style="bold red", + # Choose a backend. orca is preferred in auto mode when the tool is present. + cura_tool = find_tool(tools, "cura_slice_model") + orca_tool = find_tool(tools, "orca_slice_model") + if slicer_pref == "cura": + backend = "cura" + elif slicer_pref == "orca": + backend = "orca" + else: # auto + backend = "orca" if orca_tool is not None else "cura" + + if backend == "cura": + if cura_tool is None: + console.print( + Text( + "cura_slice_model tool not found. Is PrintMCP running?", + style="bold red", + ) ) + return + _slice_via_cura(t, thing_id, flags, cura_tool) + else: + if orca_tool is None: + console.print( + Text( + "orca_slice_model tool not found. Is OrcaSlicer installed and PrintMCP running?", + style="bold red", + ) + ) + return + _slice_via_orca(t, thing_id, flags, orca_tool) + + +def _tool_result_to_dict(result): + """Normalize a smolagents tool forward() result to a dict (or None). + + Handles dicts, JSON strings, and Pydantic models (``model_dump``) — the last + covers direct in-process calls where the MCP JSON serialization is skipped. + """ + if isinstance(result, dict): + return result + if isinstance(result, str): + try: + return json.loads(result) + except json.JSONDecodeError: + return None + model_dump = getattr(result, "model_dump", None) + if callable(model_dump): + try: + dumped = model_dump() + return dumped if isinstance(dumped, dict) else None + except (TypeError, ValueError, RuntimeError): + return None + return None + + +def _render_slice_result(t, thing_id: int, result_data: dict, title: str, temp_gcode: str) -> None: + """Shared: read the produced G-code into the DB and show a result panel.""" + gcode_path = result_data.get("gcode_path", temp_gcode) + gcode_size = result_data.get("gcode_size_bytes", 0) + stats = result_data.get("stats", {}) + settings = result_data.get("settings", {}) + + gcode_bytes = Path(gcode_path).read_bytes() + gcode_id = db.insert_thing( + thingiverse_id=t["thingiverse_id"], + name=t["name"], + creator=t["creator"], + license=t["license"], + url=t["url"], + file_name=Path(gcode_path).name, + file_size=len(gcode_bytes), + file_data=gcode_bytes, + file_type="gcode", + sliced_from=thing_id, + status="sliced", + ) + + lines = [] + printer = result_data.get("printer") or settings.get("machine") + if printer: + lines.append(Text(f"Printer: {printer}", style=f"bold {ACCENT}")) + lines.append(Text(f"G-code: {Path(gcode_path).name} ({format_size(gcode_size)})")) + if stats.get("print_time"): + lines.append(Text(f"Print time: {stats['print_time']}")) + if stats.get("filament_m") is not None: + vol = f" ({stats['filament_mm3']} mm3)" if stats.get("filament_mm3") else "" + lines.append(Text(f"Filament: {stats['filament_m']} m{vol}")) + # Cura settings carry layer_height/infill/supports; Orca settings carry machine/process/filament. + if "layer_height" in settings or "infill_density" in settings: + lh = settings.get("layer_height", "?") + inf = settings.get("infill_density", "?") + sup = "on" if settings.get("supports") else "off" + lines.append(Text(f"Settings: {lh}mm, {inf}% infill, supports {sup}")) + elif settings.get("process"): + lines.append(Text(f"Process: {settings['process']}")) + if settings.get("filament"): + lines.append(Text(f"Filament: {settings['filament']}")) + lines.append( + Text(f"Saved as: Thing #{gcode_id} (gcode, sliced from #{thing_id})") + ) + + console.print( + Panel( + Text("\n").join(lines), + title=Text(title, style="bold"), + border_style=ACCENT, ) - return + ) + +def _slice_via_cura(t, thing_id: int, flags: dict, slice_tool) -> None: + """Slice with CuraEngine (the original backend).""" suffix = Path(t["file_name"]).suffix or ".stl" - console.print(Text(f"Slicing {t['file_name']}...", style=f"bold {ACCENT}")) + console.print(Text(f"Slicing {t['file_name']} (Cura)...", style=f"bold {ACCENT}")) + + # Printer: user flag -> active printer preset's cura_printer -> tool default. + if "printer" not in flags: + active = db.get_active_printer() + cura_printer = (active or {}).get("cura_printer") or db.get_setting("cura_printer") + if cura_printer: + flags["printer"] = cura_printer temp_model = None temp_gcode = None @@ -211,81 +330,117 @@ def cmd_slice(args: list[str], tools: list) -> None: tool_kwargs.update(flags) result = slice_tool.forward(**tool_kwargs) - - if isinstance(result, dict): - result_data = result - elif isinstance(result, str): - try: - result_data = json.loads(result) - except json.JSONDecodeError: - console.print( - Text(f"Unexpected tool output: {result[:200]}", style="bold red") - ) - return - else: + result_data = _tool_result_to_dict(result) + if result_data is None: console.print( - Text(f"Unexpected tool output type: {type(result)}", style="bold red") + Text(f"Unexpected tool output: {str(result)[:200]}", style="bold red") ) return - - gcode_path = result_data.get("gcode_path", temp_gcode) - gcode_size = result_data.get("gcode_size_bytes", 0) - stats = result_data.get("stats", {}) - settings = result_data.get("settings", {}) - - gcode_bytes = Path(gcode_path).read_bytes() - gcode_id = db.insert_thing( - thingiverse_id=t["thingiverse_id"], - name=t["name"], - creator=t["creator"], - license=t["license"], - url=t["url"], - file_name=Path(gcode_path).name, - file_size=len(gcode_bytes), - file_data=gcode_bytes, - file_type="gcode", - sliced_from=thing_id, - status="sliced", + _render_slice_result( + t, thing_id, result_data, f"Sliced {t['file_name']} (Cura)", temp_gcode ) + except Exception as e: + console.print(Text(f"Slicing failed: {e}", style="bold red")) + finally: + for p in (temp_model, temp_gcode): + if p: + try: + Path(p).unlink(missing_ok=True) + except OSError: + pass - lines = [ - Text( - f"Printer: {result_data.get('printer', '?')}", - style=f"bold {ACCENT}", - ), - Text(f"G-code: {Path(gcode_path).name} ({format_size(gcode_size)})"), - ] - if stats.get("print_time"): - lines.append(Text(f"Print time: {stats['print_time']}")) - if stats.get("filament_m") is not None: - vol = f" ({stats['filament_mm3']} mm3)" if stats.get("filament_mm3") else "" - lines.append(Text(f"Filament: {stats['filament_m']} m{vol}")) - lh = settings.get("layer_height", "?") - inf = settings.get("infill_density", "?") - sup = "on" if settings.get("supports") else "off" - lines.append(Text(f"Settings: {lh}mm, {inf}% infill, supports {sup}")) - lines.append( - Text(f"Saved as: Thing #{gcode_id} (gcode, sliced from #{thing_id})") - ) - console.print( - Panel( - Text("\n").join(lines), - title=Text(f"Sliced {t['file_name']}", style="bold"), - border_style=ACCENT, +def _slice_via_orca(t, thing_id: int, flags: dict, orca_tool) -> None: + """Slice with OrcaSlicer's CLI using its 3-tier presets (machine/process/filament).""" + suffix = Path(t["file_name"]).suffix or ".stl" + console.print(Text(f"Slicing {t['file_name']} (OrcaSlicer)...", style=f"bold {ACCENT}")) + + # Presets: active printer preset -> DB settings -> defaults. This lets + # /printer use drive slicing, while /config set orca_* still works. + active = db.get_active_printer() + machine = ( + (active or {}).get("orca_machine") + or db.get_setting("orca_machine") + or ORCA_DEFAULTS["orca_machine"] + ) + process = ( + (active or {}).get("orca_process") + or db.get_setting("orca_process") + or ORCA_DEFAULTS["orca_process"] + ) + filament = ( + (active or {}).get("orca_filament") + or db.get_setting("orca_filament") + or ORCA_DEFAULTS["orca_filament"] + ) + + # Map user-set simple flags to Orca overrides (only flags actually passed). + overrides = { + ORCA_FLAG_OVERRIDES[k]: v for k, v in flags.items() if k in ORCA_FLAG_OVERRIDES + } + if "sparse_infill_density" in overrides: + overrides["sparse_infill_density"] = f"{overrides['sparse_infill_density']}%" + if flags.get("supports"): + overrides["enable_support"] = True + # Map the unified adhesion keyword to Orca's actual settings (Orca has no + # single "adhesion_type" — it splits across brim_width / raft_layers / + # skirt_loops). Only applies a non-default footprint; "skirt" (the default) + # is already what the presets use, so we leave it alone. + adhesion = (flags.get("adhesion_type") or "").lower() + if adhesion == "brim": + overrides["brim_width"] = "5" + overrides["skirt_loops"] = "0" + elif adhesion == "raft": + overrides["raft_layers"] = "3" + overrides["skirt_loops"] = "0" + overrides["brim_width"] = "0" + elif adhesion == "none": + overrides["skirt_loops"] = "0" + overrides["brim_width"] = "0" + overrides["raft_layers"] = "0" + # "skirt" or "" → leave preset defaults untouched. + + temp_model = None + temp_gcode = None + try: + with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as f: + f.write(t["file_data"]) + temp_model = f.name + temp_gcode = temp_model + ".gcode" + + # fdm_creality_common (and several other bundled machine bases) enable + # relative extrusion but rely on the GUI to inject `G92 E0` each layer; + # the raw CLI does not, and refuses to slice. Default to absolute + # extrusion unless the user overrides it. + overrides.setdefault("use_relative_e_distances", 0) + + result = orca_tool.forward( + model_path=temp_model, + machine=machine, + process=process, + filament=filament, + output_path=temp_gcode, + overrides=overrides or None, + ) + result_data = _tool_result_to_dict(result) + if result_data is None: + console.print( + Text(f"Unexpected tool output: {str(result)[:200]}", style="bold red") ) + return + _render_slice_result( + t, + thing_id, + result_data, + f"Sliced {t['file_name']} (OrcaSlicer)", + temp_gcode, ) - except Exception as e: console.print(Text(f"Slicing failed: {e}", style="bold red")) finally: - if temp_model: - try: - Path(temp_model).unlink(missing_ok=True) - except OSError: - pass - if temp_gcode: - try: - Path(temp_gcode).unlink(missing_ok=True) - except OSError: - pass + for p in (temp_model, temp_gcode): + if p: + try: + Path(p).unlink(missing_ok=True) + except OSError: + pass diff --git a/src/printpal/completer.py b/src/printpal/completer.py index d6d258c..de85a9e 100644 --- a/src/printpal/completer.py +++ b/src/printpal/completer.py @@ -16,6 +16,7 @@ "/sessions": None, "/thing": {"delete": None, "export": None}, "/slice": None, + "/printer": {"list": None, "show": None, "use": None, "add": None, "remove": None}, "/print": { "status": None, "pause": None, diff --git a/src/printpal/config.py b/src/printpal/config.py index 15c202d..01622e0 100644 --- a/src/printpal/config.py +++ b/src/printpal/config.py @@ -9,6 +9,25 @@ # When model switching is added, this will be read from DB settings. MODEL_ID = "claude-sonnet-4-6" +# Default OrcaSlicer presets used by /slice when slicing via OrcaSlicer. +# Override per-user via /config set orca_machine "...", etc. Defaults assume a +# Creality Ender-3 Pro with a 0.4 nozzle, matching the Cura default printer id. +ORCA_DEFAULTS = { + "slicer": "auto", # auto | cura | orca (which backend /slice prefers) + "orca_machine": "Creality Ender-3 Pro 0.4 nozzle", + "orca_process": "0.20mm Standard @Creality Ender3 Pro 0.4", + "orca_filament": "Creality Generic PLA", +} + +# Map PrintPal's simple /slice flags to OrcaSlicer setting keys (passed as +# `overrides` on the preset copies). Only flags the user actually sets are sent. +ORCA_FLAG_OVERRIDES = { + "layer_height": "layer_height", + "infill_density": "sparse_infill_density", + "material_print_temperature": "nozzle_temperature", + "material_bed_temperature": "bed_temperature", +} + # Settings keys that map to environment variables for PrintMCP. # DB settings override .env values — injected into os.environ on startup. SETTING_TO_ENV = { diff --git a/src/printpal/db.py b/src/printpal/db.py index b8ec044..30e66ae 100644 --- a/src/printpal/db.py +++ b/src/printpal/db.py @@ -58,6 +58,17 @@ message TEXT NOT NULL, created_at TEXT NOT NULL ); + +CREATE TABLE IF NOT EXISTS printers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE COLLATE NOCASE, + cura_printer TEXT NOT NULL DEFAULT '', + orca_machine TEXT NOT NULL DEFAULT '', + orca_process TEXT NOT NULL DEFAULT '', + orca_filament TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); """ @@ -100,6 +111,28 @@ def init_db() -> None: "ALTER TABLE sessions ADD COLUMN permissions TEXT NOT NULL DEFAULT '{}'" ) conn.commit() + + # Seed the default printer preset (idempotent) so a fresh install slices + # out of the box. Uses INSERT OR IGNORE; never overwrites a user edit. + now = datetime.now().isoformat(timespec="seconds") + conn.execute( + """ + INSERT OR IGNORE INTO printers + (name, cura_printer, orca_machine, orca_process, orca_filament, + created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + "ender-3-pro", + "creality_ender3pro", + "Creality Ender-3 Pro 0.4 nozzle", + "0.20mm Standard @Creality Ender3 Pro 0.4", + "Creality Generic PLA", + now, + now, + ), + ) + conn.commit() finally: conn.close() @@ -498,8 +531,120 @@ def delete_setting(key: str) -> bool: # --------------------------------------------------------------------------- -# Logs +# Printer presets (named slicer bundles) # --------------------------------------------------------------------------- +# A printer preset bundles everything the slicers need for one machine: +# the Cura definition id and the OrcaSlicer machine/process/filament presets. +# "/printer use " applies a preset into the active settings AND records +# the name in the `active_printer` setting, so /slice and the agent both follow. + + +def _row_to_printer(row: sqlite3.Row) -> dict[str, Any]: + return { + "id": row["id"], + "name": row["name"], + "cura_printer": row["cura_printer"], + "orca_machine": row["orca_machine"], + "orca_process": row["orca_process"], + "orca_filament": row["orca_filament"], + "created_at": row["created_at"], + "updated_at": row["updated_at"], + } + + +def list_printers() -> list[dict[str, Any]]: + """All printer presets, alphabetically by name.""" + conn = _get_conn() + try: + rows = conn.execute("SELECT * FROM printers ORDER BY name").fetchall() + return [_row_to_printer(r) for r in rows] + finally: + conn.close() + + +def get_printer(name: str) -> dict[str, Any] | None: + """A printer preset by name (case-insensitive), or None.""" + conn = _get_conn() + try: + row = conn.execute( + "SELECT * FROM printers WHERE name = ? COLLATE NOCASE", (name,) + ).fetchone() + return _row_to_printer(row) if row else None + finally: + conn.close() + + +def upsert_printer( + name: str, + cura_printer: str = "", + orca_machine: str = "", + orca_process: str = "", + orca_filament: str = "", +) -> None: + """Insert or update a printer preset by name.""" + now = datetime.now().isoformat(timespec="seconds") + conn = _get_conn() + try: + conn.execute( + """ + INSERT INTO printers (name, cura_printer, orca_machine, orca_process, + orca_filament, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(name) DO UPDATE SET + cura_printer = excluded.cura_printer, + orca_machine = excluded.orca_machine, + orca_process = excluded.orca_process, + orca_filament = excluded.orca_filament, + updated_at = excluded.updated_at + """, + (name, cura_printer, orca_machine, orca_process, orca_filament, now, now), + ) + conn.commit() + finally: + conn.close() + + +def delete_printer(name: str) -> bool: + """Delete a printer preset by name. Returns True if one was removed.""" + conn = _get_conn() + try: + cursor = conn.execute( + "DELETE FROM printers WHERE name = ? COLLATE NOCASE", (name,) + ) + conn.commit() + return cursor.rowcount > 0 + finally: + conn.close() + + +def get_active_printer() -> dict[str, Any] | None: + """The printer preset referenced by the `active_printer` setting, or None.""" + name = get_setting("active_printer") + if not name: + return None + return get_printer(name) + + +def use_printer(name: str) -> dict[str, Any] | None: + """Apply a printer preset into active settings and record it as active. + + Writes the preset's values into the individual config keys (slicer reads + those), then sets `active_printer`. Returns the preset, or None if the name + doesn't exist. + """ + p = get_printer(name) + if p is None: + return None + if p["cura_printer"]: + set_setting("cura_printer", p["cura_printer"]) + if p["orca_machine"]: + set_setting("orca_machine", p["orca_machine"]) + if p["orca_process"]: + set_setting("orca_process", p["orca_process"]) + if p["orca_filament"]: + set_setting("orca_filament", p["orca_filament"]) + set_setting("active_printer", p["name"]) + return p def log_message(level: str, message: str) -> None: diff --git a/src/printpal/scanner.py b/src/printpal/scanner.py index 098d1dc..9805f33 100644 --- a/src/printpal/scanner.py +++ b/src/printpal/scanner.py @@ -33,7 +33,10 @@ def scan_for_downloads(agent) -> None: if "thingiverse_download_model" in step.code_action: _insert_download_from_step(step) db.mark_step_scanned(step_key) - elif "cura_slice_model" in step.code_action: + elif ( + "cura_slice_model" in step.code_action + or "orca_slice_model" in step.code_action + ): _insert_slice_from_step(step) db.mark_step_scanned(step_key) diff --git a/tests/test_printer_presets.py b/tests/test_printer_presets.py new file mode 100644 index 0000000..e09ca0b --- /dev/null +++ b/tests/test_printer_presets.py @@ -0,0 +1,267 @@ +"""Offline tests for printer presets and /slice's preset resolution. + +No slicer, no network, no real ~/.printpal — every test isolates the DB by +monkeypatching db.DB_PATH to a temp file. The /slice backend-selection and +preset-resolution are exercised with stub tool objects whose forward() writes a +G-code file (so the DB insert path runs for real), capturing the kwargs. +""" + +from __future__ import annotations + +import pytest + +from printpal import db +from printpal.commands import thing as pp_thing +from printpal.config import ORCA_DEFAULTS + + +@pytest.fixture() +def tmp_db(tmp_path, monkeypatch): + """Fresh, isolated PrintPal DB for each test.""" + monkeypatch.setattr(db, "DB_PATH", tmp_path / "printpal.db") + db.init_db() + return tmp_path / "printpal.db" + + +def _seed_model(model_bytes: bytes = b"\x00" * 128): + return db.insert_thing( + thingiverse_id=None, + name="m.stl", + creator=None, + license=None, + url=None, + file_name="m.stl", + file_size=len(model_bytes), + file_data=model_bytes, + file_type="model", + status="downloaded", + ) + + +class _StubOrcaTool: + """Stands in for orca_slice_model: records kwargs, writes a gcode file.""" + + name = "orca_slice_model" + + def __init__(self): + self.kwargs = None + + def forward(self, **kwargs): + self.kwargs = kwargs + from pathlib import Path + + Path(kwargs["output_path"]).write_bytes(b"; generated by OrcaSlicer\nG1 X0\n") + return { + "gcode_path": kwargs["output_path"], + "gcode_size_bytes": 40, + "settings": { + "machine": kwargs["machine"], + "process": kwargs["process"], + "filament": kwargs["filament"], + }, + "stats": {}, + } + + +class _StubCuraTool: + name = "cura_slice_model" + + def __init__(self): + self.kwargs = None + + def forward(self, **kwargs): + self.kwargs = kwargs + from pathlib import Path + + Path(kwargs["output_path"]).write_bytes(b"; Cura G-code\n") + return { + "gcode_path": kwargs["output_path"], + "gcode_size_bytes": 20, + "printer": kwargs.get("printer", "?"), + "settings": {}, + "stats": {}, + } + + +def _gcode_things(): + return [t for t in db.list_things() if t["file_type"] == "gcode"] + + +# --------------------------------------------------------------------------- # +# DB CRUD +# --------------------------------------------------------------------------- # +def test_default_printer_seeded(tmp_db): + names = {p["name"] for p in db.list_printers()} + assert "ender-3-pro" in names + e3 = db.get_printer("ender-3-pro") + assert e3 is not None + assert e3["cura_printer"] == "creality_ender3pro" + assert e3["orca_machine"] == ORCA_DEFAULTS["orca_machine"] + + +def test_upsert_and_get_case_insensitive(tmp_db): + db.upsert_printer("Voron 2.4", cura_printer="voron24", orca_machine="Voron 2.4 0.4 nozzle") + p = db.get_printer("voron 2.4") + assert p is not None + assert p["name"] == "Voron 2.4" + assert p["cura_printer"] == "voron24" + + +def test_upsert_overwrites_existing(tmp_db): + db.upsert_printer("x", orca_machine="M1") + db.upsert_printer("X", orca_machine="M2") # case-insensitive unique + p = db.get_printer("x") + assert p is not None and p["orca_machine"] == "M2" + assert len([q for q in db.list_printers() if q["name"].lower() == "x"]) == 1 + + +def test_delete_printer(tmp_db): + db.upsert_printer("gone") + assert db.delete_printer("gone") is True + assert db.get_printer("gone") is None + assert db.delete_printer("gone") is False + + +def test_use_printer_writes_settings_and_active(tmp_db): + db.upsert_printer( + "myprinter", + cura_printer="my_cura", + orca_machine="My Machine", + orca_process="My Process", + orca_filament="My PLA", + ) + applied = db.use_printer("myprinter") + assert applied is not None + assert db.get_setting("cura_printer") == "my_cura" + assert db.get_setting("orca_machine") == "My Machine" + assert db.get_setting("orca_process") == "My Process" + assert db.get_setting("orca_filament") == "My PLA" + active = db.get_active_printer() + assert active is not None and active["name"] == "myprinter" + + +def test_use_printer_unknown_returns_none(tmp_db): + assert db.use_printer("nope") is None + assert db.get_active_printer() is None + + +def test_remove_active_printer_clears_applied_settings(tmp_db, monkeypatch): + """Removing the active printer must clear the settings it applied so /slice + doesn't keep using a deleted printer's presets.""" + from printpal.commands import printer as pp_printer + + monkeypatch.setattr(pp_printer, "prompt_yes_no", lambda _msg: True) + db.upsert_printer("v24", orca_machine="Voron", orca_process="P", orca_filament="F", cura_printer="voron24") + db.use_printer("v24") + assert db.get_setting("orca_machine") == "Voron" + + pp_printer.cmd_printer(["remove", "v24"]) + assert db.get_active_printer() is None + assert db.get_setting("orca_machine") is None # cleared, not left dangling + + +# --------------------------------------------------------------------------- # +# /slice: backend selection + preset resolution via active printer +# --------------------------------------------------------------------------- # +def test_slice_auto_prefers_orca(tmp_db): + pp_thing.cmd_slice([str(_seed_model())], [_StubOrcaTool(), _StubCuraTool()]) + g = _gcode_things() + assert g, "expected a gcode thing" + src = db.get_thing(g[0]["id"]) + assert src is not None + assert bytes(src["file_data"]).startswith(b"; generated by OrcaSlicer") + + +def test_slice_orca_uses_active_printer_presets(tmp_db): + db.upsert_printer( + "v24", + orca_machine="Voron 2.4 0.4 nozzle", + orca_process="Voron Process", + orca_filament="Voron PLA", + ) + db.use_printer("v24") + + tool = _StubOrcaTool() + pp_thing.cmd_slice([str(_seed_model())], [tool]) + + assert tool.kwargs is not None + assert tool.kwargs["machine"] == "Voron 2.4 0.4 nozzle" + assert tool.kwargs["process"] == "Voron Process" + assert tool.kwargs["filament"] == "Voron PLA" + # absolute extrusion default is injected + assert tool.kwargs["overrides"]["use_relative_e_distances"] == 0 + + +def test_slice_orca_falls_back_to_config_then_defaults(tmp_db, monkeypatch): + # No active printer, but a config override set -> config wins over defaults. + db.set_setting("orca_machine", "Config Machine") + tool = _StubOrcaTool() + pp_thing.cmd_slice([str(_seed_model())], [tool]) + assert tool.kwargs is not None + assert tool.kwargs["machine"] == "Config Machine" + + # Fresh DB + no config: defaults win. + db.delete_setting("orca_machine") + tool2 = _StubOrcaTool() + pp_thing.cmd_slice([str(_seed_model(b"\x01" * 128))], [tool2]) + assert tool2.kwargs is not None + assert tool2.kwargs["machine"] == ORCA_DEFAULTS["orca_machine"] + + +def test_slice_force_cura_uses_active_printer_cura_id(tmp_db): + db.upsert_printer("v24", cura_printer="voron24_cura") + db.use_printer("v24") + tool = _StubCuraTool() + pp_thing.cmd_slice([str(_seed_model()), "--slicer", "cura"], [tool]) + assert tool.kwargs is not None + assert tool.kwargs["printer"] == "voron24_cura" + + +def test_slice_cura_flag_beats_active_printer(tmp_db): + db.upsert_printer("v24", cura_printer="voron24_cura") + db.use_printer("v24") + tool = _StubCuraTool() + pp_thing.cmd_slice( + [str(_seed_model()), "--slicer", "cura", "--printer", "prusa_mk4"], [tool] + ) + assert tool.kwargs is not None + assert tool.kwargs["printer"] == "prusa_mk4" # explicit flag wins + + +# --------------------------------------------------------------------------- # +# Adhesion mapping: the unified keyword must translate to Orca's real settings +# (brim_width / raft_layers / skirt_loops), NOT a nonexistent "adhesion_type". +# --------------------------------------------------------------------------- # +def _orca_overrides_for(args): + tool = _StubOrcaTool() + pp_thing.cmd_slice([str(_seed_model()), "--slicer", "orca"] + args, [tool]) + assert tool.kwargs is not None + return tool.kwargs["overrides"] + + +def test_orca_adhesion_brim(tmp_db): + ov = _orca_overrides_for(["--adhesion", "brim"]) + assert ov["brim_width"] == "5" + assert ov["skirt_loops"] == "0" + assert "adhesion_type" not in ov # must not leak the Cura keyword + assert ov["use_relative_e_distances"] == 0 # still injected + + +def test_orca_adhesion_raft(tmp_db): + ov = _orca_overrides_for(["--adhesion", "raft"]) + assert ov["raft_layers"] == "3" + assert ov["skirt_loops"] == "0" + assert ov["brim_width"] == "0" + + +def test_orca_adhesion_none(tmp_db): + ov = _orca_overrides_for(["--adhesion", "none"]) + assert ov["skirt_loops"] == "0" + assert ov["brim_width"] == "0" + assert ov["raft_layers"] == "0" + + +def test_orca_infill_percent_and_supports(tmp_db): + ov = _orca_overrides_for(["--infill", "40", "--supports"]) + assert ov["sparse_infill_density"] == "40%" # % suffix + assert ov["enable_support"] is True diff --git a/tests/test_printmcp_command.py b/tests/test_printmcp_command.py new file mode 100644 index 0000000..1cedf60 --- /dev/null +++ b/tests/test_printmcp_command.py @@ -0,0 +1,53 @@ +"""Tests for the PrintMCP server-command override in app.main's bootstrap. + +PrintPal launches PrintMCP as a stdio subprocess. By default it uses the +published PyPI package (``uvx printmcp``); setting PRINTPAL_PRINTMCP_COMMAND +lets a developer point at a local checkout instead (e.g. +``uv run --directory /path/to/PrintMCP printmcp``). These tests pin that +behavior so the default path can't regress. +""" + +from __future__ import annotations + +from printpal.app import _printmcp_server_params + + +def test_default_is_pypi_uvx(monkeypatch): + """No override -> default to the published server via uvx.""" + monkeypatch.delenv("PRINTPAL_PRINTMCP_COMMAND", raising=False) + params, source = _printmcp_server_params() + assert params.command == "uvx" + assert params.args == ["printmcp"] + assert "pypi" in source + assert (params.env or {}).get("PYTHONUNBUFFERED") == "1" + + +def test_override_command_and_label(monkeypatch): + """An override is shlex-split with args and labeled as local.""" + monkeypatch.setenv( + "PRINTPAL_PRINTMCP_COMMAND", + "uv run --directory '/opt/PrintMCP checkout' printmcp", + ) + params, source = _printmcp_server_params() + assert params.command == "uv" + # shlex removed the quotes around the spaced path but kept it one arg. + assert params.args == ["run", "--directory", "/opt/PrintMCP checkout", "printmcp"] + assert source.startswith("local (") + assert "printmcp" in source + + +def test_override_python_module_form(monkeypatch): + """python -m printmcp works too (venv with printmcp installed).""" + monkeypatch.setenv("PRINTPAL_PRINTMCP_COMMAND", "python -m printmcp") + params, source = _printmcp_server_params() + assert params.command == "python" + assert params.args == ["-m", "printmcp"] + assert "local" in source + + +def test_whitespace_override_falls_back_to_default(monkeypatch): + """A whitespace-only override is treated as unset.""" + monkeypatch.setenv("PRINTPAL_PRINTMCP_COMMAND", " ") + params, source = _printmcp_server_params() + assert params.command == "uvx" + assert "pypi" in source diff --git a/tests/test_scanner_orca.py b/tests/test_scanner_orca.py new file mode 100644 index 0000000..d1259e5 --- /dev/null +++ b/tests/test_scanner_orca.py @@ -0,0 +1,102 @@ +"""Test that the memory scanner ingests agent-invoked orca_slice_model results. + +The scanner (scanner.py) walks agent memory after agent.run() and persists any +download/slice files into the things table. It must recognize orca_slice_model +the same way it recognizes cura_slice_model. Offline: uses a fake "step" and a +temp DB. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from printpal import db +from printpal.scanner import scan_for_downloads + + +@pytest.fixture() +def tmp_db(tmp_path, monkeypatch): + monkeypatch.setattr(db, "DB_PATH", tmp_path / "printpal.db") + db.init_db() + return tmp_path + + +class _FakeStep: + """Minimal stand-in for a smolagents memory step.""" + + def __init__(self, step_number, code_action, action_output=None, observations=""): + self.step_number = step_number + self.code_action = code_action + self.action_output = action_output + self.observations = observations + self.is_final_answer = False + + +class _FakeAgent: + def __init__(self, steps): + self.memory = type("M", (), {"steps": steps})() + + +def _seed_model_thing(tmp_path: Path): + model = tmp_path / "cube.stl" + model.write_bytes(b"\x00" * 100) + return db.insert_thing( + thingiverse_id=None, + name="cube", + creator=None, + license=None, + url=None, + file_name="cube.stl", + file_size=100, + file_data=model.read_bytes(), + file_type="model", + status="downloaded", + ) + + +def test_scan_ingests_orca_slice_step(tmp_db): + thing_id = _seed_model_thing(tmp_db) + # Make a real gcode file on disk that the scanner will read + delete. + gcode = Path(tmp_db) / "cube.gcode" + gcode.write_bytes(b"; generated by OrcaSlicer\nG1 X0\n") + + result = { + "slicer": "orcaslicer", + "model": "cube.stl", + "gcode_path": str(gcode), + "gcode_size_bytes": gcode.stat().st_size, + "settings": {"machine": "M", "process": "P", "filament": "F"}, + "stats": {"print_time": "24m 21s", "filament_m": 1.331}, + } + step = _FakeStep(1, code_action="orca_slice_model(...)", action_output=result) + scan_for_downloads(_FakeAgent([step])) + + gcodes = [t for t in db.list_things() if t["file_type"] == "gcode"] + assert gcodes, "expected a gcode thing from the orca step" + full = db.get_thing(gcodes[0]["id"]) + assert full is not None + assert bytes(full["file_data"]).startswith(b"; generated by OrcaSlicer") + # Scanner links it back to the source model and deletes the disk file. + assert gcodes[0]["sliced_from"] == thing_id + assert not gcode.exists() + + +def test_scan_ingests_cura_slice_step_still_works(tmp_db): + thing_id = _seed_model_thing(tmp_db) + gcode = Path(tmp_db) / "cube_cura.gcode" + gcode.write_bytes(b";FLAVOR:Marlin\nG1 X0\n") + result = { + "printer": "creality_ender3pro", + "model": "cube.stl", + "gcode_path": str(gcode), + "gcode_size_bytes": gcode.stat().st_size, + "settings": {"layer_height": 0.2, "infill_density": 20, "supports": False}, + "stats": {}, + } + step = _FakeStep(1, code_action="cura_slice_model(...)", action_output=result) + scan_for_downloads(_FakeAgent([step])) + gcodes = [t for t in db.list_things() if t["file_type"] == "gcode"] + assert gcodes, "cura slice must still be ingested" + assert gcodes[0]["sliced_from"] == thing_id diff --git a/tests/test_slice_orca_integration.py b/tests/test_slice_orca_integration.py new file mode 100644 index 0000000..a15596b --- /dev/null +++ b/tests/test_slice_orca_integration.py @@ -0,0 +1,122 @@ +"""Integration test: PrintPal's /slice drives the local PrintMCP OrcaSlicer tool. + +This is a REAL end-to-end test of the cross-repo seam: + PrintPal cmd_slice -> PrintMCP orca_slice_model (local checkout) + -> OrcaSlicer CLI -> G-code -> G-code BLOB in PrintPal DB. + +It imports printmcp from the sibling PrintMCP checkout (``../PrintMCP/src``), +so both repos must live side by side. It requires OrcaSlicer installed (native or +Flatpak); on machines without it the test is skipped. Mark ``integration`` so it can +be deselected with ``-m "not integration"``. +""" + +from __future__ import annotations + +import asyncio +import struct +import sys +from pathlib import Path + +import pytest + +# Make the sibling PrintMCP source importable (or fall back to an installed printmcp). +# tests/ -> PrintPal/ -> / ; PrintMCP is a sibling of PrintPal. +_PRINTMCP_SRC = Path(__file__).resolve().parents[2] / "PrintMCP" / "src" +if _PRINTMCP_SRC.is_dir() and str(_PRINTMCP_SRC) not in sys.path: + sys.path.insert(0, str(_PRINTMCP_SRC)) + +pytest.importorskip("printmcp.config", reason="PrintMCP checkout not found next to PrintPal") +pytest.importorskip("printmcp.orca", reason="PrintMCP checkout has no orca module") + +from printmcp.config import get_orca_paths +from printmcp.orca import orca_slice_model + +from printpal import db as pp_db +from printpal.commands import thing as pp_thing + + +def _write_cube_stl(path) -> None: + """Write a valid closed 20mm binary cube STL (12 triangles).""" + V = [(0, 0, 0), (20, 0, 0), (20, 20, 0), (0, 20, 0), + (0, 0, 20), (20, 0, 20), (20, 20, 20), (0, 20, 20)] + F = [(0, 3, 2), (0, 2, 1), (4, 5, 6), (4, 6, 7), (0, 1, 5), (0, 5, 4), + (2, 3, 7), (2, 7, 6), (1, 2, 6), (1, 6, 5), (0, 4, 7), (0, 7, 3)] + + def nrm(a, b, c): + import math + ux, uy, uz = [b[i] - a[i] for i in range(3)] + vx, vy, vz = [c[i] - a[i] for i in range(3)] + n = (uy * vz - uz * vy, uz * vx - ux * vz, ux * vy - uy * vx) + L = math.sqrt(sum(x * x for x in n)) or 1 + return tuple(x / L for x in n) + + with open(path, "wb") as fh: + fh.write(b"\0" * 80) + fh.write(struct.pack(" bool: + try: + get_orca_paths() + return True + except (FileNotFoundError, OSError, RuntimeError): + return False + + +class _Tool: + """Minimal stand-in for an MCP tool object: has .name and .forward(), and + drives the (async) PrintMCP coroutine to completion synchronously.""" + + def __init__(self, name, coro_fn): + self.name = name + self._coro_fn = coro_fn + + def forward(self, **kwargs): + return asyncio.run(self._coro_fn(**kwargs)) + + +@pytest.mark.integration +def test_slice_via_orca_end_to_end(tmp_path, monkeypatch): + if not _orca_available(): + pytest.skip("OrcaSlicer not available on this machine") + + # Isolate the app DB so we don't touch the user's ~/.printpal database. + monkeypatch.setattr(pp_db, "DB_PATH", tmp_path / "printpal.db") + pp_db.init_db() + + # Seed a real model thing into the isolated DB. + stl = tmp_path / "cube.stl" + _write_cube_stl(stl) + model_bytes = stl.read_bytes() + thing_id = pp_db.insert_thing( + thingiverse_id=None, + name="integration-cube", + creator=None, + license=None, + url=None, + file_name="cube.stl", + file_size=len(model_bytes), + file_data=model_bytes, + file_type="model", + status="downloaded", + ) + + tools = [_Tool("orca_slice_model", orca_slice_model)] + + # Drive the real /slice command; --slicer orca forces the Orca backend. + pp_thing.cmd_slice([str(thing_id), "--slicer", "orca"], tools) + + # A gcode thing derived from the model must now exist in the DB. + gcodes = [t for t in pp_db.list_things() if t["file_type"] == "gcode"] + assert gcodes, "expected a gcode thing after slicing" + g = gcodes[0] + assert g["sliced_from"] == thing_id + # list_things() omits the BLOB; fetch the full row to verify the G-code bytes. + full = pp_db.get_thing(g["id"]) + assert full is not None + data = full["file_data"] + assert data is not None and len(data) > 1000 + assert b"; generated by OrcaSlicer" in bytes(data[:400]) From db7ecea53b3a6c681396049f1b26608093cb8aba Mon Sep 17 00:00:00 2001 From: sbussiso Date: Mon, 7 Sep 2026 17:00:57 -0700 Subject: [PATCH 2/3] docs: AGENTS.md reflects OrcaSlicer + /printer + shlex args + test layout - What-is stack: slicing via CuraEngine or OrcaSlicer. - File table: printer.py command module, shlex command-arg parsing, _printmcp_server_params() and the PRINTPAL_PRINTMCP_COMMAND local-checkout override, ORCA_DEFAULTS/ORCA_FLAG_OVERRIDES. - Scanner detects orca_slice_model too. - Testing section now matches reality (uv run python -m pytest, temp-DB isolation, integration marker) instead of 'no tests currently'. --- AGENTS.md | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4b4421a..8591962 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,14 +4,14 @@ Instructions for AI coding agents working on the PrintPal codebase. ## What is PrintPal? -PrintPal is a terminal-based 3D printing assistant. It uses an AI agent (smolagents + LiteLLM) to drive a 3D printing pipeline via an MCP server called PrintMCP. The agent searches Thingiverse, downloads models, slices them with CuraEngine, and prints them via OctoPrint — all from a REPL with slash commands. +PrintPal is a terminal-based 3D printing assistant. It uses an AI agent (smolagents + LiteLLM) to drive a 3D printing pipeline via an MCP server called PrintMCP. The agent searches Thingiverse, downloads models, slices them with CuraEngine **or OrcaSlicer**, and prints them via OctoPrint — all from a REPL with slash commands. ## Tech stack - **Python 3.10+** (target `>=3.10` in pyproject.toml) - **uv** for dependency management — `uv sync` to install, `uv run printpal` to run - **smolagents** (HuggingFace) — the agent framework that runs the LLM and executes tool calls -- **PrintMCP** (PyPI: `printmcp`) — the MCP server providing Thingiverse/Cura/OctoPrint tools, launched via `uvx printmcp` +- **PrintMCP** (PyPI: `printmcp`) — the MCP server providing Thingiverse/Cura/OrcaSlicer/OctoPrint tools, launched via `uvx printmcp` - **Rich** — terminal UI (panels, tables, styled text) - **prompt_toolkit** — input handling (placeholder text, arrow-key history, Shift+Tab key bindings) - **SQLite** — everything is stored in `~/.printpal/printpal.db` (sessions, things as BLOBs, settings, logs) @@ -20,13 +20,13 @@ PrintPal is a terminal-based 3D printing assistant. It uses an AI agent (smolage | File | Responsibility | Key things to know | |------|---------------|-------------------| -| `src/printpal/app.py` | Entry point, bootstrap, REPL loop, command dispatch | Contains `_inject_db_settings()`, `_sanitize_session_name()`, error resilience wrappers, auto-save logic. `server_params` is created after DB settings injection so PrintMCP gets the right env. `MODEL_ID` is imported from `config.py`. | -| `src/printpal/config.py` | App-level constants | `MODEL_ID`, `SETTING_TO_ENV` (DB key → env var mapping), `TIPS`, `EXAMPLES`. When model switching is added, the model registry will live here. | -| `src/printpal/commands/` | All `/slash` command implementations (sub-package) | `__init__.py` re-exports all `cmd_*` functions + `COMMANDS` dict. Split by domain: `session.py`, `thing.py`, `print.py`, `mode.py`, `admin.py`, `help.py`, `helpers.py`. | +| `src/printpal/app.py` | Entry point, bootstrap, REPL loop, command dispatch | Contains `_inject_db_settings()`, `_sanitize_session_name()`, error resilience wrappers, auto-save logic. Command args are parsed with `shlex` (quoted values work). `_printmcp_server_params()` picks `uvx printmcp` (PyPI) by default, or the `PRINTPAL_PRINTMCP_COMMAND` override for a local checkout. `MODEL_ID` is imported from `config.py`. | +| `src/printpal/config.py` | App-level constants | `MODEL_ID`, `SETTING_TO_ENV` (DB key → env var mapping), `ORCA_DEFAULTS` + `ORCA_FLAG_OVERRIDES` (default OrcaSlicer presets + slice-flag → setting map), `TIPS`, `EXAMPLES`. When model switching is added, the model registry will live here. | +| `src/printpal/commands/` | All `/slash` command implementations (sub-package) | `__init__.py` re-exports all `cmd_*` functions + `COMMANDS` dict. Split by domain: `session.py`, `thing.py` (incl. `/slice` with Cura/Orca backend pick), `printer.py` (`/printer` presets), `print.py`, `mode.py`, `admin.py`, `help.py`, `helpers.py`. | | `src/printpal/permissions.py` | Permission system | `PermissionTool` subclasses `smolagents.Tool` (required by CodeAgent's isinstance check). Wraps each MCP tool, intercepts `forward()`. Three modes: DEFAULT (ask all), AUTO (read-only/safe auto-approved), BYPASS (everything auto-approved). Per-session allow-list stored in DB. | | `src/printpal/sessions.py` | Session save/load | Serializes `AgentMemory` steps to JSON, reconstructs on load. Drops `model_input_messages` on load (not needed for continuation). `load_session()` returns a dict with `success`, `prompt_history`, and `permissions`. | | `src/printpal/db.py` | SQLite layer | Every function opens/closes its own connection (uses WAL mode). `init_db()` adds columns incrementally for schema migrations. `migrate_to_blob_storage()` converts old `file_path` rows to `file_data` BLOBs. | -| `src/printpal/scanner.py` | Download + slice scanner | Walks agent memory after `agent.run()` to detect `thingiverse_download_model` and `cura_slice_model` calls. Reads files from disk into BLOBs, deletes disk files. | +| `src/printpal/scanner.py` | Download + slice scanner | Walks agent memory after `agent.run()` to detect `thingiverse_download_model` and (`cura_slice_model` or `orca_slice_model`) calls. Reads files from disk into BLOBs, deletes disk files. | | `src/printpal/completer.py` | Command autocomplete | `CommandCompleter` does prefix matching on slash commands. Only activates when input starts with `/`. | | `src/printpal/pricing.py` | Model pricing data | `MODEL_PRICING` dict (per-1M-token prices), `MASKED_KEYS`, `RESTART_KEYS`. | | `src/printpal/ui.py` | Shared UI constants + helpers | `console = Console(highlight=False)`, `ACCENT = "#d4b702"` (matches smolagents' yellow), `LOGO` (ASCII art), `format_size()`, `format_duration()`, `prompt_yes_no()`, `make_bar()` (visual progress bars), `format_tokens()` (compact token formatting). Import from here, don't create new Console instances. | @@ -44,7 +44,7 @@ agent = CodeAgent(tools=wrapped_tools, model=model) `/slice`, `/print`, etc. call MCP tools directly via `call_tool()` or `tool.forward()` — no LLM, no tokens spent. These bypass the permission system since they're user-initiated. ### Download + slice scanner -smolagents wraps all MCP tool calls inside a Python code executor, so `step.tool_calls[0].name` is always `python_interpreter`, not the actual tool name. The scanner (in `scanner.py`) checks `step.code_action` for `thingiverse_download_model` and `cura_slice_model`, parses the results from `step.observations` using `ast.literal_eval`, reads the files from disk into BLOBs, and deletes the disk files. +smolagents wraps all MCP tool calls inside a Python code executor, so `step.tool_calls[0].name` is always `python_interpreter`, not the actual tool name. The scanner (in `scanner.py`) checks `step.code_action` for `thingiverse_download_model`/`cura_slice_model`/`orca_slice_model`, parses the results from `step.observations` using `ast.literal_eval`, reads the files from disk into BLOBs, and deletes the disk files. ### mcpadapt monkeypatch `jsonref.replace_refs` (used by mcpadapt) returns lazy proxy objects that aren't JSON-serializable. We monkeypatch it in `app.py` to deep-copy the result. This is a known mcpadapt 0.1.20 bug. @@ -64,16 +64,17 @@ The REPL loop has nested try/except: outer catches `EOFError`/`KeyboardInterrupt | `OCTOPRINT_URL` | `.env` or `/config` | OctoPrint server URL | | `OCTOPRINT_API_KEY` | `.env` or `/config` | OctoPrint API key | | `PRINTMCP_CURA_DIR` | `.env` or `/config` | Cura install path (auto-detected if unset) | +| `PRINTMCP_ORCA_COMMAND` | `.env` or `/config` | OrcaSlicer launch command (auto-detected if unset) | +| `PRINTMCP_ORCA_PROFILES` | `.env` or `/config` | OrcaSlicer bundled presets dir (auto-detected if unset) | +| `PRINTPAL_PRINTMCP_COMMAND` | env only | Override the PrintMCP launch command (e.g. `uv run --directory /path/to/PrintMCP printmcp`) to use a local checkout instead of PyPI. | DB settings (via `/config set`) override `.env` values. They're injected into `os.environ` on startup before the MCP server starts. ## Testing -No tests currently. When adding tests: -- MCP tools should be mocked (no real Thingiverse/OctoPrint calls) -- DB tests should use a temp SQLite file, not `~/.printpal/printpal.db` -- Use `pytest` (in dev dependencies): `uv run pytest` -- Lint with `uvx ruff check src tests` (or `uvx ruff check .`) +`pytest` (in dev dependencies) — `uv run python -m pytest` from the repo root. Tests are offline +and isolate the DB (monkeypatch `db.DB_PATH` to a temp file). Mark end-to-end tests with +`@pytest.mark.integration`. Lint with `uvx ruff check src tests` (or `uvx ruff check .`). ## Things to avoid From eabf4064cbb8b50b8a33a807dac5b38698de7334 Mon Sep 17 00:00:00 2001 From: sbussiso Date: Mon, 7 Sep 2026 17:16:02 -0700 Subject: [PATCH 3/3] ci: pin ruff rule set + fix real lint/format issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pyproject: add [tool.ruff] select = [E, F, I, N, UP, B] (matching PrintMCP) with E501 handled by the formatter. Recent ruff's broader defaults flagged the REPL's deliberate never-crash guards and the DB's naive-localtime convention; pinning keeps CI stable as ruff evolves. - commands/__init__.py: keep COMMANDS before the submodule imports (help.py imports it at module load) and silence the resulting E402 with per-line noqas — fixes a circular-import regression from the autofix pass. - helpers.py: rename unused 'default' loop var (B007), add 'from e' (B904). - ruff format applied across src + tests to satisfy the 'ruff format --check' gate (the repo never ran it before). All 23 tests pass; both ruff gates clean. --- pyproject.toml | 14 ++++++ src/printpal/app.py | 70 +++++++++++--------------- src/printpal/commands/__init__.py | 75 ++++++++++++++++------------ src/printpal/commands/admin.py | 32 +++--------- src/printpal/commands/help.py | 2 +- src/printpal/commands/helpers.py | 41 +++++---------- src/printpal/commands/mode.py | 24 +++------ src/printpal/commands/print.py | 64 ++++++------------------ src/printpal/commands/printer.py | 27 +++++----- src/printpal/commands/session.py | 8 ++- src/printpal/commands/thing.py | 46 +++++------------ src/printpal/db.py | 48 +++++------------- src/printpal/permissions.py | 13 ++--- src/printpal/scanner.py | 10 +--- tests/test_printer_presets.py | 8 +-- tests/test_slice_orca_integration.py | 47 ++++++++++++----- 16 files changed, 218 insertions(+), 311 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1b96009..a2db6cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,3 +30,17 @@ packages = ["src/printpal"] markers = [ "integration: end-to-end tests that require real tools (e.g. OrcaSlicer) or sibling checkouts; deselect with -m 'not integration'", ] + +[tool.ruff] +line-length = 100 +target-version = "py310" + +[tool.ruff.lint] +# Keep CI stable as ruff evolves: select a fixed, intentional rule set (the +# pycodestyle/pyflakes/isort/pep8-naming/pyupgrade/bugbear family), matching +# what PrintMCP enforces. Newer ruff's broader defaults (blind-except, naive +# datetimes, try/except-pass, nested-if hints) flag the REPL's deliberate +# never-crash guards and the DB's local-time timestamp convention. +select = ["E", "F", "I", "N", "UP", "B"] +# Long lines are handled by the formatter; don't double-flag them. +ignore = ["E501"] diff --git a/src/printpal/app.py b/src/printpal/app.py index 99ee525..a53a327 100644 --- a/src/printpal/app.py +++ b/src/printpal/app.py @@ -8,15 +8,15 @@ from __future__ import annotations import copy -import jsonref +import os import random import re import sys -import os -from smolagents import CodeAgent, LiteLLMModel, MCPClient -from mcp import StdioServerParameters +import jsonref from dotenv import load_dotenv +from mcp import StdioServerParameters +from smolagents import CodeAgent, LiteLLMModel, MCPClient try: from prompt_toolkit.formatted_text import FormattedText @@ -30,39 +30,39 @@ from rich.panel import Panel from rich.text import Text -from .ui import console, ACCENT, LOGO, make_bar, format_tokens from . import db -from .config import MODEL_ID, SETTING_TO_ENV, TIPS, EXAMPLES -from .permissions import PermissionState, PermissionTool, ApprovalMode -from .completer import CommandCompleter -from .scanner import scan_for_downloads -from .sessions import migrate_json_sessions, save_session, _resolve_name from .commands import ( COMMANDS, - cmd_save, + cmd_backup, + cmd_config, + cmd_cost, + cmd_help, cmd_load, - cmd_sessions, - cmd_thing_dispatch, - cmd_slice, + cmd_logs, + cmd_mode, cmd_print, - cmd_print_status, - cmd_print_pause, - cmd_print_resume, cmd_print_cancel, cmd_print_connect, cmd_print_disconnect, cmd_print_files, + cmd_print_pause, cmd_print_queue, - cmd_mode, + cmd_print_resume, + cmd_print_status, cmd_printer, - cmd_config, - cmd_cost, - cmd_logs, - cmd_backup, + cmd_save, cmd_self_destruct, - cmd_help, + cmd_sessions, + cmd_slice, + cmd_thing_dispatch, prompt_save_if_dirty, ) +from .completer import CommandCompleter +from .config import EXAMPLES, MODEL_ID, SETTING_TO_ENV, TIPS +from .permissions import ApprovalMode, PermissionState, PermissionTool +from .scanner import scan_for_downloads +from .sessions import _resolve_name, migrate_json_sessions, save_session +from .ui import ACCENT, LOGO, console, format_tokens, make_bar load_dotenv() @@ -89,11 +89,11 @@ def _inject_db_settings() -> None: """Load DB settings into os.environ (overriding .env values).""" settings = db.get_all_settings() for db_key, env_key in SETTING_TO_ENV.items(): - if db_key in settings and settings[db_key]: + if settings.get(db_key): os.environ[env_key] = settings[db_key] -def _printmcp_server_params() -> tuple["StdioServerParameters", str]: +def _printmcp_server_params() -> tuple[StdioServerParameters, str]: """Build the StdioServerParameters for the PrintMCP server. Default: ``uvx printmcp`` (the release published on PyPI). Set the @@ -386,9 +386,7 @@ def main(): migrate_json_sessions() except Exception as e: console.print(Text(f"Database error: {e}", style="bold red")) - console.print( - Text("Try /self-destruct to reset, or check ~/.printpal/", style="dim") - ) + console.print(Text("Try /self-destruct to reset, or check ~/.printpal/", style="dim")) return try: @@ -419,9 +417,7 @@ def main(): while True: try: - _print_status_line( - tool_count, perm_state, agent, current_session_name - ) + _print_status_line(tool_count, perm_state, agent, current_session_name) user_input = _read_prompt(history, perm_state) except (EOFError, KeyboardInterrupt): console.print() @@ -472,9 +468,7 @@ def main(): perm_state.to_json(), ): dirty = False - current_session_name = ( - args[0] if args else current_session_name - ) + current_session_name = args[0] if args else current_session_name continue if cmd == "/load": @@ -492,9 +486,7 @@ def main(): history = InMemoryHistory() for p in result["prompt_history"]: history.append_string(p) - perm_state = PermissionState.from_json( - result["permissions"] - ) + perm_state = PermissionState.from_json(result["permissions"]) for wt in wrapped_tools: wt._perm = perm_state resolved = _resolve_name(args[0]) @@ -598,9 +590,7 @@ def main(): except Exception as e: console.print(Text(f"Error: {e}", style="bold red")) try: - db.log_message( - "ERROR", f"Command '{cmd}': {type(e).__name__}: {e}" - ) + db.log_message("ERROR", f"Command '{cmd}': {type(e).__name__}: {e}") except Exception: pass continue diff --git a/src/printpal/commands/__init__.py b/src/printpal/commands/__init__.py index 0734460..786103a 100644 --- a/src/printpal/commands/__init__.py +++ b/src/printpal/commands/__init__.py @@ -2,6 +2,11 @@ Re-exports all command functions and the COMMANDS dict so that ``app.py`` can import everything from a single package. + +NOTE on import order: ``COMMANDS`` must be defined before these submodule +imports run, because ``help.py`` does ``from . import COMMANDS`` at import time. +That placement makes these imports land after module-level code, so each carries +``# noqa: E402``. """ from __future__ import annotations @@ -37,61 +42,67 @@ "/help": "Show available commands.", } +from .admin import ( # noqa: E402 + cmd_backup, + cmd_config, + cmd_cost, + cmd_logs, + cmd_self_destruct, +) +from .help import cmd_help # noqa: E402 from .helpers import ( # noqa: E402 - find_tool, call_tool, - prompt_save_if_dirty, - parse_slice_flags, + find_tool, parse_gcode_temps, + parse_slice_flags, preheat, + prompt_save_if_dirty, ) -from .session import cmd_save, cmd_load, cmd_sessions # noqa: E402 -from .thing import cmd_thing, cmd_thing_dispatch, cmd_slice # noqa: E402 -from .printer import cmd_printer # noqa: E402 +from .mode import cmd_mode # noqa: E402 from .print import ( # noqa: E402 cmd_print, - cmd_print_status, - cmd_print_pause, - cmd_print_resume, cmd_print_cancel, cmd_print_connect, cmd_print_disconnect, cmd_print_files, + cmd_print_pause, cmd_print_queue, + cmd_print_resume, + cmd_print_status, ) -from .mode import cmd_mode # noqa: E402 -from .admin import cmd_config, cmd_cost, cmd_logs, cmd_backup, cmd_self_destruct # noqa: E402 -from .help import cmd_help # noqa: E402 +from .printer import cmd_printer # noqa: E402 +from .session import cmd_load, cmd_save, cmd_sessions # noqa: E402 +from .thing import cmd_slice, cmd_thing, cmd_thing_dispatch # noqa: E402 __all__ = [ "COMMANDS", - "find_tool", "call_tool", - "prompt_save_if_dirty", - "parse_slice_flags", - "parse_gcode_temps", - "preheat", - "cmd_save", + "cmd_backup", + "cmd_config", + "cmd_cost", + "cmd_help", "cmd_load", - "cmd_sessions", - "cmd_thing", - "cmd_thing_dispatch", - "cmd_slice", - "cmd_printer", + "cmd_logs", + "cmd_mode", "cmd_print", - "cmd_print_status", - "cmd_print_pause", - "cmd_print_resume", "cmd_print_cancel", "cmd_print_connect", "cmd_print_disconnect", "cmd_print_files", + "cmd_print_pause", "cmd_print_queue", - "cmd_mode", - "cmd_config", - "cmd_cost", - "cmd_logs", - "cmd_backup", + "cmd_print_resume", + "cmd_print_status", + "cmd_printer", + "cmd_save", "cmd_self_destruct", - "cmd_help", + "cmd_sessions", + "cmd_slice", + "cmd_thing", + "cmd_thing_dispatch", + "find_tool", + "parse_gcode_temps", + "parse_slice_flags", + "preheat", + "prompt_save_if_dirty", ] diff --git a/src/printpal/commands/admin.py b/src/printpal/commands/admin.py index 656412f..a58e59d 100644 --- a/src/printpal/commands/admin.py +++ b/src/printpal/commands/admin.py @@ -9,9 +9,9 @@ from rich.table import Table from rich.text import Text -from ..ui import console, ACCENT, prompt_yes_no, format_size, make_bar from .. import db -from ..pricing import MODEL_PRICING, MASKED_KEYS, RESTART_KEYS +from ..pricing import MASKED_KEYS, MODEL_PRICING, RESTART_KEYS +from ..ui import ACCENT, console, format_size, make_bar, prompt_yes_no def cmd_config(args: list[str]) -> None: @@ -64,9 +64,7 @@ def cmd_config(args: list[str]) -> None: ) ) if key in RESTART_KEYS: - console.print( - Text(" Restart PrintPal to apply this setting.", style="yellow") - ) + console.print(Text(" Restart PrintPal to apply this setting.", style="yellow")) elif sub == "get": if len(args) < 2: @@ -91,11 +89,7 @@ def cmd_config(args: list[str]) -> None: console.print(Text(f" {key} was not set", style="dim")) else: - console.print( - Text( - "Usage: /config [set |get |unset ]", style="dim" - ) - ) + console.print(Text("Usage: /config [set |get |unset ]", style="dim")) def cmd_cost(agent, model_id: str) -> None: @@ -174,9 +168,7 @@ def cmd_logs(args: list[str]) -> None: console.print(Text("No log entries.", style="dim")) return - table = Table( - show_header=True, header_style="bold", box=box.HORIZONTALS, border_style=ACCENT - ) + table = Table(show_header=True, header_style="bold", box=box.HORIZONTALS, border_style=ACCENT) table.add_column("ID", style="dim", width=5) table.add_column("Level", width=8) table.add_column("Message", min_width=30) @@ -185,11 +177,7 @@ def cmd_logs(args: list[str]) -> None: for log in logs: level_str = log["level"] level_style = ( - "bold red" - if level_str == "ERROR" - else "yellow" - if level_str == "WARNING" - else "dim" + "bold red" if level_str == "ERROR" else "yellow" if level_str == "WARNING" else "dim" ) table.add_row( str(log["id"]), @@ -291,9 +279,7 @@ def cmd_self_destruct() -> bool: return False if confirmation != "DELETE": - console.print( - Text("Confirmation did not match. Self-destruct aborted.", style="dim") - ) + console.print(Text("Confirmation did not match. Self-destruct aborted.", style="dim")) return False # Delete everything @@ -302,9 +288,7 @@ def cmd_self_destruct() -> bool: try: if printpal_dir.exists(): shutil.rmtree(str(printpal_dir)) - console.print( - Text(" All PrintPal data has been permanently deleted.", style="bold red") - ) + console.print(Text(" All PrintPal data has been permanently deleted.", style="bold red")) return True except Exception as e: console.print(Text(f" Error during deletion: {e}", style="bold red")) diff --git a/src/printpal/commands/help.py b/src/printpal/commands/help.py index 6e2f962..280bb94 100644 --- a/src/printpal/commands/help.py +++ b/src/printpal/commands/help.py @@ -6,7 +6,7 @@ from rich.table import Table from rich.text import Text -from ..ui import console, ACCENT +from ..ui import ACCENT, console from . import COMMANDS diff --git a/src/printpal/commands/helpers.py b/src/printpal/commands/helpers.py index 77942e8..5c89e5e 100644 --- a/src/printpal/commands/helpers.py +++ b/src/printpal/commands/helpers.py @@ -14,9 +14,8 @@ from rich.live import Live from rich.text import Text -from ..ui import console, ACCENT, prompt_yes_no, format_duration from ..sessions import next_default_name, save_session - +from ..ui import ACCENT, console, format_duration, prompt_yes_no # --------------------------------------------------------------------------- # Tool helpers @@ -95,7 +94,7 @@ def parse_slice_flags(args: list[str]) -> dict: while i < len(args): arg = args[i].lower() matched = False - for long_flag, (short_flag, key, typ, default, bounds) in SLICE_FLAGS.items(): + for long_flag, (short_flag, key, typ, _default, bounds) in SLICE_FLAGS.items(): if arg != long_flag and arg != short_flag: continue matched = True @@ -107,11 +106,9 @@ def parse_slice_flags(args: list[str]) -> dict: i += 1 val = args[i] try: - parsed = ( - int(val) if typ is int else float(val) if typ is float else val - ) - except ValueError: - raise ValueError(f"Invalid value for {arg}: {val}") + parsed = int(val) if typ is int else float(val) if typ is float else val + except ValueError as e: + raise ValueError(f"Invalid value for {arg}: {val}") from e if bounds: lo, hi = bounds if parsed < lo or parsed > hi: @@ -189,13 +186,9 @@ def preheat(tools: list, tool_temp: int, bed_temp: int) -> bool: try: with Live(console=console, refresh_per_second=1) as live: while elapsed < max_wait: - status = call_tool( - tools, "octoprint_get_status", response_format="json" - ) + status = call_tool(tools, "octoprint_get_status", response_format="json") if not status: - live.update( - Text("Could not read printer status.", style="bold red") - ) + live.update(Text("Could not read printer status.", style="bold red")) break temps = status.get("temperatures", {}) @@ -207,12 +200,8 @@ def preheat(tools: list, tool_temp: int, bed_temp: int) -> bool: bed_ok = abs(bed_actual - bed_temp) <= 2 tool_ok = abs(tool_actual - tool_temp) <= 2 - bed_pct = min( - 100, int((bed_actual / bed_temp * 100) if bed_temp else 100) - ) - tool_pct = min( - 100, int((tool_actual / tool_temp * 100) if tool_temp else 100) - ) + bed_pct = min(100, int((bed_actual / bed_temp * 100) if bed_temp else 100)) + tool_pct = min(100, int((tool_actual / tool_temp * 100) if tool_temp else 100)) bed_bar = "█" * (bed_pct // 10) + "░" * (10 - bed_pct // 10) tool_bar = "█" * (tool_pct // 10) + "░" * (10 - tool_pct // 10) @@ -230,20 +219,14 @@ def preheat(tools: list, tool_temp: int, bed_temp: int) -> bool: live.update(Group(*lines)) return True if elapsed > 0: - lines.append( - Text(f" Elapsed: {format_duration(elapsed)}", style="dim") - ) + lines.append(Text(f" Elapsed: {format_duration(elapsed)}", style="dim")) live.update(Group(*lines)) time.sleep(poll_interval) elapsed += poll_interval - console.print( - Text(" Preheat timeout (10 min). Proceeding anyway.", style="yellow") - ) + console.print(Text(" Preheat timeout (10 min). Proceeding anyway.", style="yellow")) return True except KeyboardInterrupt: - console.print( - Text("\n Preheat interrupted. Proceeding anyway.", style="yellow") - ) + console.print(Text("\n Preheat interrupted. Proceeding anyway.", style="yellow")) return True diff --git a/src/printpal/commands/mode.py b/src/printpal/commands/mode.py index 20b227a..dded8fd 100644 --- a/src/printpal/commands/mode.py +++ b/src/printpal/commands/mode.py @@ -5,8 +5,8 @@ from rich.panel import Panel from rich.text import Text -from ..ui import console, ACCENT, prompt_yes_no -from ..permissions import ApprovalMode, READ_ONLY, SAFE_ACTIONS, PHYSICAL +from ..permissions import PHYSICAL, READ_ONLY, SAFE_ACTIONS, ApprovalMode +from ..ui import ACCENT, console, prompt_yes_no def cmd_mode(args: list[str], perm_state) -> None: @@ -24,21 +24,13 @@ def cmd_mode(args: list[str], perm_state) -> None: for mode in ApprovalMode: marker = "\u25cf" if mode == current else "\u25cb" warning = " \u26a0" if mode == ApprovalMode.BYPASS else "" - lines.append( - Text(f" {marker} {mode.value:8s} \u2014 {mode.label}{warning}") - ) + lines.append(Text(f" {marker} {mode.value:8s} \u2014 {mode.label}{warning}")) lines.append(Text("")) lines.append(Text("Tool Categories:", style="bold")) - lines.append( - Text(f" \U0001f441 Read-only: {', '.join(sorted(READ_ONLY))}") - ) - lines.append( - Text(f" \u2702 Safe actions: {', '.join(sorted(SAFE_ACTIONS))}") - ) - lines.append( - Text(f" \U0001f527 Physical: {', '.join(sorted(PHYSICAL))}") - ) + lines.append(Text(f" \U0001f441 Read-only: {', '.join(sorted(READ_ONLY))}")) + lines.append(Text(f" \u2702 Safe actions: {', '.join(sorted(SAFE_ACTIONS))}")) + lines.append(Text(f" \U0001f527 Physical: {', '.join(sorted(PHYSICAL))}")) if perm_state.allow_list: lines.append(Text("")) @@ -86,6 +78,4 @@ def cmd_mode(args: list[str], perm_state) -> None: if new_mode == ApprovalMode.BYPASS else "dim" ) - console.print( - Text(f"Mode set to {new_mode.label} ({new_mode.value}).", style=f"bold {style}") - ) + console.print(Text(f"Mode set to {new_mode.label} ({new_mode.value}).", style=f"bold {style}")) diff --git a/src/printpal/commands/print.py b/src/printpal/commands/print.py index 9c1dca0..50fbb33 100644 --- a/src/printpal/commands/print.py +++ b/src/printpal/commands/print.py @@ -12,8 +12,8 @@ from rich.table import Table from rich.text import Text -from ..ui import console, ACCENT, prompt_yes_no, format_size, format_duration, make_bar from .. import db +from ..ui import ACCENT, console, format_duration, format_size, make_bar, prompt_yes_no from .helpers import call_tool, parse_gcode_temps, preheat @@ -55,11 +55,7 @@ def cmd_print(args: list[str], tools: list) -> None: ready = status.get("ready", False) conn_state = status.get("connection", {}).get("state", "unknown") - if ( - "Offline" in str(conn_state) - or "Closed" in str(conn_state) - or conn_state == "unknown" - ): + if "Offline" in str(conn_state) or "Closed" in str(conn_state) or conn_state == "unknown": if prompt_yes_no("Printer is not connected. Connect now? [y/N] "): result = call_tool( tools, @@ -69,9 +65,7 @@ def cmd_print(args: list[str], tools: list) -> None: response_format="json", ) if result is None: - console.print( - Text("Failed to connect to the printer.", style="bold red") - ) + console.print(Text("Failed to connect to the printer.", style="bold red")) return time.sleep(2) status = call_tool(tools, "octoprint_get_status", response_format="json") @@ -80,22 +74,16 @@ def cmd_print(args: list[str], tools: list) -> None: if not ready: queue = db.get_queue() queue_msg = f" ({len(queue)} item(s) in queue)" if queue else "" - if prompt_yes_no( - f"Printer is busy or not ready{queue_msg}. Add to print queue? [y/N] " - ): + if prompt_yes_no(f"Printer is busy or not ready{queue_msg}. Add to print queue? [y/N] "): pos = db.add_to_queue(thing_id) - console.print( - Text(f"Added to queue (position {pos}).", style=f"bold {ACCENT}") - ) + console.print(Text(f"Added to queue (position {pos}).", style=f"bold {ACCENT}")) return if not no_preheat: tool_temp, bed_temp = parse_gcode_temps(t["file_data"]) preheat(tools, tool_temp, bed_temp) - console.print( - Text(f"Uploading {t['file_name']} to OctoPrint...", style=f"bold {ACCENT}") - ) + console.print(Text(f"Uploading {t['file_name']} to OctoPrint...", style=f"bold {ACCENT}")) temp_gcode = None try: with tempfile.NamedTemporaryFile(suffix=".gcode", delete=False) as f: @@ -154,9 +142,7 @@ def cmd_print(args: list[str], tools: list) -> None: return db.update_thing_status(thing_id, "printing") - console.print( - Text("Print started! Use /print status to monitor.", style=f"bold {ACCENT}") - ) + console.print(Text("Print started! Use /print status to monitor.", style=f"bold {ACCENT}")) job = call_tool(tools, "octoprint_get_job", response_format="json") if job: @@ -190,15 +176,11 @@ def _build_status_panel(tools: list) -> Panel: server = status.get("server", {}) if server.get("version"): lines.append( - Text( - f"OctoPrint: {server.get('version', '?')} (API {server.get('api', '?')})" - ) + Text(f"OctoPrint: {server.get('version', '?')} (API {server.get('api', '?')})") ) conn = status.get("connection", {}) lines.append(Text(f"Connection: {conn.get('state', 'unknown') or 'unknown'}")) - lines.append( - Text(f"State: {status.get('printer_state', 'unknown') or 'unknown'}") - ) + lines.append(Text(f"State: {status.get('printer_state', 'unknown') or 'unknown'}")) lines.append(Text(f"Ready: {'yes' if status.get('ready') else 'no'}")) temps = status.get("temperatures", {}) @@ -214,9 +196,7 @@ def _build_status_panel(tools: list) -> Panel: bar = make_bar(actual, target, 10) reached = " \u2713" if abs(actual - target) <= 2 else "" lines.append( - Text( - f" {label:6s} {actual:.0f}\u00b0C \u2192 {target}\u00b0C {bar}{reached}" - ) + Text(f" {label:6s} {actual:.0f}\u00b0C \u2192 {target}\u00b0C {bar}{reached}") ) else: lines.append(Text(f" {label:6s} {actual:.0f}\u00b0C")) @@ -242,9 +222,7 @@ def _build_status_panel(tools: list) -> Panel: lines.append(Text("")) lines.append(Text(f"Print Queue: {len(queue)} item(s)", style="bold")) for q in queue[:3]: - lines.append( - Text(f" #{q['position']}: Thing #{q['thing_id']} ({q['name']})") - ) + lines.append(Text(f" #{q['position']}: Thing #{q['thing_id']} ({q['name']})")) lines.append(Text("")) lines.append(Text("Press Ctrl+C to stop monitoring.", style="dim")) @@ -402,17 +380,13 @@ def cmd_print_disconnect(tools: list) -> None: def cmd_print_files(tools: list) -> None: result = call_tool(tools, "octoprint_list_files", response_format="json") if result is None: - console.print( - Text("Could not list files. Is OctoPrint configured?", style="bold red") - ) + console.print(Text("Could not list files. Is OctoPrint configured?", style="bold red")) return files = result.get("files", []) if not files: console.print(Text("No G-code files on the server.", style="dim")) return - table = Table( - show_header=True, header_style="bold", box=box.HORIZONTALS, border_style=ACCENT - ) + table = Table(show_header=True, header_style="bold", box=box.HORIZONTALS, border_style=ACCENT) table.add_column("Path", style=f"bold {ACCENT}", min_width=20) table.add_column("Size", justify="right", width=10) table.add_column("Est. Time", width=15) @@ -437,27 +411,21 @@ def cmd_print_queue(args: list[str]) -> None: console.print(Text("Position must be a number.", style="bold red")) return if db.remove_from_queue(pos): - console.print( - Text(f"Removed position {pos} from queue.", style=f"bold {ACCENT}") - ) + console.print(Text(f"Removed position {pos} from queue.", style=f"bold {ACCENT}")) else: console.print(Text(f"No queue item at position {pos}.", style="bold red")) return if args and args[0] == "clear": count = db.clear_queue() - console.print( - Text(f"Cleared {count} item(s) from queue.", style=f"bold {ACCENT}") - ) + console.print(Text(f"Cleared {count} item(s) from queue.", style=f"bold {ACCENT}")) return queue = db.get_queue() if not queue: console.print(Text("Print queue is empty.", style="dim")) return - table = Table( - show_header=True, header_style="bold", box=box.HORIZONTALS, border_style=ACCENT - ) + table = Table(show_header=True, header_style="bold", box=box.HORIZONTALS, border_style=ACCENT) table.add_column("Pos", style="dim", width=5) table.add_column("Thing ID", width=10) table.add_column("Name", style=f"bold {ACCENT}", min_width=20) diff --git a/src/printpal/commands/printer.py b/src/printpal/commands/printer.py index 3078d1c..81ca98c 100644 --- a/src/printpal/commands/printer.py +++ b/src/printpal/commands/printer.py @@ -48,9 +48,7 @@ def _list() -> None: console.print(Text("No printer presets. Add one with /printer add.", style="dim")) return - table = Table( - show_header=True, header_style="bold", box=box.HORIZONTALS, border_style=ACCENT - ) + table = Table(show_header=True, header_style="bold", box=box.HORIZONTALS, border_style=ACCENT) table.add_column("", width=2) # active marker table.add_column("Name", style=f"bold {ACCENT}", min_width=14) table.add_column("Cura printer", style="dim") @@ -78,7 +76,9 @@ def _show(args: list[str]) -> None: if not name: active = db.get_active_printer() if active is None: - console.print(Text("No active printer. /printer use to select one.", style="dim")) + console.print( + Text("No active printer. /printer use to select one.", style="dim") + ) return p = active else: @@ -90,7 +90,9 @@ def _show(args: list[str]) -> None: active = db.get_active_printer() is_active = active is not None and active["name"].lower() == p["name"].lower() lines = [ - Text(f"Name: {p['name']}{' (active)' if is_active else ''}", style=f"bold {ACCENT}"), + Text( + f"Name: {p['name']}{' (active)' if is_active else ''}", style=f"bold {ACCENT}" + ), Text(f"Cura printer: {p['cura_printer'] or '—'}"), Text(f"Orca machine: {p['orca_machine'] or '—'}"), Text(f"Orca process: {p['orca_process'] or '—'}"), @@ -109,13 +111,9 @@ def _use(args: list[str]) -> None: applied = db.use_printer(name) if applied is None: near = ", ".join(p["name"] for p in db.list_printers()) or "(none)" - console.print( - Text(f"No printer preset '{name}'. Available: {near}", style="bold red") - ) + console.print(Text(f"No printer preset '{name}'. Available: {near}", style="bold red")) return - console.print( - Text(f"Active printer: {applied['name']}", style=f"bold {ACCENT}") - ) + console.print(Text(f"Active printer: {applied['name']}", style=f"bold {ACCENT}")) console.print(Text(f" Orca machine: {applied['orca_machine'] or '—'}", style="dim")) console.print(Text(f" Orca process: {applied['orca_process'] or '—'}", style="dim")) console.print(Text(f" Orca filament: {applied['orca_filament'] or '—'}", style="dim")) @@ -128,7 +126,12 @@ def _parse_add_opts(args: list[str]) -> tuple[str, dict[str, str]]: raise ValueError("Usage: /printer add [options]") name = args[0] opts = {"cura": "", "machine": "", "process": "", "filament": ""} - flagmap = {"--cura": "cura", "--machine": "machine", "--process": "process", "--filament": "filament"} + flagmap = { + "--cura": "cura", + "--machine": "machine", + "--process": "process", + "--filament": "filament", + } i = 1 while i < len(args): flag = args[i] diff --git a/src/printpal/commands/session.py b/src/printpal/commands/session.py index 550424d..4efc119 100644 --- a/src/printpal/commands/session.py +++ b/src/printpal/commands/session.py @@ -6,9 +6,9 @@ from rich.table import Table from rich.text import Text -from ..ui import console, ACCENT, prompt_yes_no from .. import db -from ..sessions import next_default_name, save_session, load_session, list_sessions +from ..sessions import list_sessions, load_session, next_default_name, save_session +from ..ui import ACCENT, console, prompt_yes_no from .helpers import prompt_save_if_dirty @@ -70,9 +70,7 @@ def cmd_sessions() -> None: if not sessions: console.print(Text("No saved sessions.", style="dim")) return - table = Table( - show_header=True, header_style="bold", box=box.HORIZONTALS, border_style=ACCENT - ) + table = Table(show_header=True, header_style="bold", box=box.HORIZONTALS, border_style=ACCENT) table.add_column("ID", style="dim", width=5) table.add_column("Name", style=f"bold {ACCENT}", min_width=20) table.add_column("Steps", justify="right", width=6) diff --git a/src/printpal/commands/thing.py b/src/printpal/commands/thing.py index 7a266f3..edfb037 100644 --- a/src/printpal/commands/thing.py +++ b/src/printpal/commands/thing.py @@ -22,9 +22,7 @@ def cmd_thing(args: list[str]) -> None: if not things: console.print(Text("No things in database.", style="dim")) return - table = Table( - show_header=True, header_style="bold", box=box.HORIZONTALS, border_style=ACCENT - ) + table = Table(show_header=True, header_style="bold", box=box.HORIZONTALS, border_style=ACCENT) table.add_column("ID", style="dim", width=5) table.add_column("Name", style=f"bold {ACCENT}", min_width=20) table.add_column("Type", width=8) @@ -65,11 +63,7 @@ def cmd_thing_detail(thing_id: int) -> None: if t["sliced_from"]: lines.append(Text(f"Sliced from: #{t['sliced_from']}")) if t["file_data"] is not None: - lines.append( - Text( - f"In DB: yes ({format_size(len(t['file_data']))})", style="green" - ) - ) + lines.append(Text(f"In DB: yes ({format_size(len(t['file_data']))})", style="green")) else: lines.append(Text("In DB: no", style="red")) console.print( @@ -136,9 +130,7 @@ def cmd_thing_dispatch(args: list[str]) -> None: elif args[0].isdigit(): cmd_thing_detail(int(args[0])) else: - console.print( - Text("Usage: /thing [id|export [dest]|delete ]", style="dim") - ) + console.print(Text("Usage: /thing [id|export [dest]|delete ]", style="dim")) def cmd_slice(args: list[str], tools: list) -> None: @@ -152,9 +144,7 @@ def cmd_slice(args: list[str], tools: list) -> None: ) return if not args[0].isdigit(): - console.print( - Text("First argument must be a thing ID (number).", style="bold red") - ) + console.print(Text("First argument must be a thing ID (number).", style="bold red")) return thing_id = int(args[0]) @@ -165,11 +155,11 @@ def cmd_slice(args: list[str], tools: list) -> None: return # Pop the control key (not a tool kwarg). --slicer cura|orca forces a backend. - slicer_pref = (flags.pop("_slicer", None) or db.get_setting("slicer") or ORCA_DEFAULTS["slicer"]).lower() + slicer_pref = ( + flags.pop("_slicer", None) or db.get_setting("slicer") or ORCA_DEFAULTS["slicer"] + ).lower() if slicer_pref not in ("auto", "cura", "orca"): - console.print( - Text(f"Unknown slicer '{slicer_pref}' (use cura or orca).", style="bold red") - ) + console.print(Text(f"Unknown slicer '{slicer_pref}' (use cura or orca).", style="bold red")) return t = db.get_thing(thing_id) @@ -186,9 +176,7 @@ def cmd_slice(args: list[str], tools: list) -> None: return if t["file_data"] is None: console.print( - Text( - f"Thing #{thing_id} has no file data in the database.", style="bold red" - ) + Text(f"Thing #{thing_id} has no file data in the database.", style="bold red") ) return @@ -289,9 +277,7 @@ def _render_slice_result(t, thing_id: int, result_data: dict, title: str, temp_g lines.append(Text(f"Process: {settings['process']}")) if settings.get("filament"): lines.append(Text(f"Filament: {settings['filament']}")) - lines.append( - Text(f"Saved as: Thing #{gcode_id} (gcode, sliced from #{thing_id})") - ) + lines.append(Text(f"Saved as: Thing #{gcode_id} (gcode, sliced from #{thing_id})")) console.print( Panel( @@ -332,9 +318,7 @@ def _slice_via_cura(t, thing_id: int, flags: dict, slice_tool) -> None: result = slice_tool.forward(**tool_kwargs) result_data = _tool_result_to_dict(result) if result_data is None: - console.print( - Text(f"Unexpected tool output: {str(result)[:200]}", style="bold red") - ) + console.print(Text(f"Unexpected tool output: {str(result)[:200]}", style="bold red")) return _render_slice_result( t, thing_id, result_data, f"Sliced {t['file_name']} (Cura)", temp_gcode @@ -375,9 +359,7 @@ def _slice_via_orca(t, thing_id: int, flags: dict, orca_tool) -> None: ) # Map user-set simple flags to Orca overrides (only flags actually passed). - overrides = { - ORCA_FLAG_OVERRIDES[k]: v for k, v in flags.items() if k in ORCA_FLAG_OVERRIDES - } + overrides = {ORCA_FLAG_OVERRIDES[k]: v for k, v in flags.items() if k in ORCA_FLAG_OVERRIDES} if "sparse_infill_density" in overrides: overrides["sparse_infill_density"] = f"{overrides['sparse_infill_density']}%" if flags.get("supports"): @@ -424,9 +406,7 @@ def _slice_via_orca(t, thing_id: int, flags: dict, orca_tool) -> None: ) result_data = _tool_result_to_dict(result) if result_data is None: - console.print( - Text(f"Unexpected tool output: {str(result)[:200]}", style="bold red") - ) + console.print(Text(f"Unexpected tool output: {str(result)[:200]}", style="bold red")) return _render_slice_result( t, diff --git a/src/printpal/db.py b/src/printpal/db.py index 30e66ae..b73f68e 100644 --- a/src/printpal/db.py +++ b/src/printpal/db.py @@ -88,17 +88,13 @@ def init_db() -> None: conn.commit() # Add file_data column if it doesn't exist (for existing DBs) - columns = [ - row[1] for row in conn.execute("PRAGMA table_info(things)").fetchall() - ] + columns = [row[1] for row in conn.execute("PRAGMA table_info(things)").fetchall()] if "file_data" not in columns: conn.execute("ALTER TABLE things ADD COLUMN file_data BLOB") conn.commit() # Add prompt_history column to sessions if it doesn't exist - sess_cols = [ - row[1] for row in conn.execute("PRAGMA table_info(sessions)").fetchall() - ] + sess_cols = [row[1] for row in conn.execute("PRAGMA table_info(sessions)").fetchall()] if "prompt_history" not in sess_cols: conn.execute( "ALTER TABLE sessions ADD COLUMN prompt_history TEXT NOT NULL DEFAULT '[]'" @@ -107,9 +103,7 @@ def init_db() -> None: # Add permissions column to sessions if it doesn't exist if "permissions" not in sess_cols: - conn.execute( - "ALTER TABLE sessions ADD COLUMN permissions TEXT NOT NULL DEFAULT '{}'" - ) + conn.execute("ALTER TABLE sessions ADD COLUMN permissions TEXT NOT NULL DEFAULT '{}'") conn.commit() # Seed the default printer preset (idempotent) so a fresh install slices @@ -151,9 +145,7 @@ def migrate_to_blob_storage() -> int: conn = _get_conn() try: # Check if file_path column exists - columns = [ - row[1] for row in conn.execute("PRAGMA table_info(things)").fetchall() - ] + columns = [row[1] for row in conn.execute("PRAGMA table_info(things)").fetchall()] if "file_path" not in columns: return 0 @@ -259,9 +251,7 @@ def get_session_by_name(name: str) -> dict[str, Any] | None: def get_session_by_id(session_id: int) -> dict[str, Any] | None: conn = _get_conn() try: - row = conn.execute( - "SELECT * FROM sessions WHERE id = ?", (session_id,) - ).fetchone() + row = conn.execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone() return dict(row) if row else None finally: conn.close() @@ -270,9 +260,7 @@ def get_session_by_id(session_id: int) -> dict[str, Any] | None: def list_all_sessions() -> list[dict[str, Any]]: conn = _get_conn() try: - rows = conn.execute( - "SELECT * FROM sessions ORDER BY updated_at DESC" - ).fetchall() + rows = conn.execute("SELECT * FROM sessions ORDER BY updated_at DESC").fetchall() return [dict(r) for r in rows] finally: conn.close() @@ -348,9 +336,7 @@ def get_thing_file_data(thing_id: int) -> bytes | None: """Return just the file_data BLOB for a thing, or None.""" conn = _get_conn() try: - row = conn.execute( - "SELECT file_data FROM things WHERE id = ?", (thing_id,) - ).fetchone() + row = conn.execute("SELECT file_data FROM things WHERE id = ?", (thing_id,)).fetchone() return row["file_data"] if row else None finally: conn.close() @@ -403,9 +389,7 @@ def update_thing_status(thing_id: int, status: str) -> None: def is_step_scanned(step_key: str) -> bool: conn = _get_conn() try: - row = conn.execute( - "SELECT 1 FROM scanned_steps WHERE step_key = ?", (step_key,) - ).fetchone() + row = conn.execute("SELECT 1 FROM scanned_steps WHERE step_key = ?", (step_key,)).fetchone() return row is not None finally: conn.close() @@ -414,9 +398,7 @@ def is_step_scanned(step_key: str) -> bool: def mark_step_scanned(step_key: str) -> None: conn = _get_conn() try: - conn.execute( - "INSERT OR IGNORE INTO scanned_steps (step_key) VALUES (?)", (step_key,) - ) + conn.execute("INSERT OR IGNORE INTO scanned_steps (step_key) VALUES (?)", (step_key,)) conn.commit() finally: conn.close() @@ -490,9 +472,7 @@ def clear_queue() -> int: def get_setting(key: str) -> str | None: conn = _get_conn() try: - row = conn.execute( - "SELECT value FROM settings WHERE key = ?", (key,) - ).fetchone() + row = conn.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone() return row["value"] if row else None finally: conn.close() @@ -608,9 +588,7 @@ def delete_printer(name: str) -> bool: """Delete a printer preset by name. Returns True if one was removed.""" conn = _get_conn() try: - cursor = conn.execute( - "DELETE FROM printers WHERE name = ? COLLATE NOCASE", (name,) - ) + cursor = conn.execute("DELETE FROM printers WHERE name = ? COLLATE NOCASE", (name,)) conn.commit() return cursor.rowcount > 0 finally: @@ -668,9 +646,7 @@ def get_logs(limit: int = 20, level: str | None = None) -> list[dict[str, Any]]: (level.upper(), limit), ).fetchall() else: - rows = conn.execute( - "SELECT * FROM logs ORDER BY id DESC LIMIT ?", (limit,) - ).fetchall() + rows = conn.execute("SELECT * FROM logs ORDER BY id DESC LIMIT ?", (limit,)).fetchall() return [dict(r) for r in rows] finally: conn.close() diff --git a/src/printpal/permissions.py b/src/printpal/permissions.py index 57b3614..f6b9b67 100644 --- a/src/printpal/permissions.py +++ b/src/printpal/permissions.py @@ -16,12 +16,11 @@ from enum import Enum from typing import Any +import smolagents from rich.panel import Panel from rich.text import Text -import smolagents -from .ui import console, ACCENT - +from .ui import ACCENT, console # --------------------------------------------------------------------------- # Tool categories @@ -175,9 +174,7 @@ def _prompt_approval(tool_name: str, args: dict | None = None) -> str: Text(f"Category: {category}"), ] if args: - arg_str = ", ".join( - f"{k}={v}" for k, v in args.items() if k != "response_format" - ) + arg_str = ", ".join(f"{k}={v}" for k, v in args.items() if k != "response_format") if arg_str: lines.append(Text(f"Args: {arg_str[:120]}")) @@ -235,9 +232,7 @@ def forward(self, *args, **kwargs) -> Any: if args and len(args) == 1 and isinstance(args[0], dict): display_args = args[0] elif kwargs: - display_args = { - k: v for k, v in kwargs.items() if k != "response_format" - } + display_args = {k: v for k, v in kwargs.items() if k != "response_format"} response = _prompt_approval(self.name, display_args) diff --git a/src/printpal/scanner.py b/src/printpal/scanner.py index 9805f33..00542b2 100644 --- a/src/printpal/scanner.py +++ b/src/printpal/scanner.py @@ -33,10 +33,7 @@ def scan_for_downloads(agent) -> None: if "thingiverse_download_model" in step.code_action: _insert_download_from_step(step) db.mark_step_scanned(step_key) - elif ( - "cura_slice_model" in step.code_action - or "orca_slice_model" in step.code_action - ): + elif "cura_slice_model" in step.code_action or "orca_slice_model" in step.code_action: _insert_slice_from_step(step) db.mark_step_scanned(step_key) @@ -85,10 +82,7 @@ def _insert_download_from_step(step) -> None: "thing_id": int(thing_match.group(1)) if thing_match else None, "name": name_match.group(1).strip() if name_match else "Unknown", "license": license_match.group(1).strip() if license_match else None, - "files": [ - {"name": m[0], "size_bytes": int(m[1]), "path": m[2]} - for m in file_matches - ], + "files": [{"name": m[0], "size_bytes": int(m[1]), "path": m[2]} for m in file_matches], } if not data or not data.get("files"): diff --git a/tests/test_printer_presets.py b/tests/test_printer_presets.py index e09ca0b..4ac1707 100644 --- a/tests/test_printer_presets.py +++ b/tests/test_printer_presets.py @@ -151,7 +151,9 @@ def test_remove_active_printer_clears_applied_settings(tmp_db, monkeypatch): from printpal.commands import printer as pp_printer monkeypatch.setattr(pp_printer, "prompt_yes_no", lambda _msg: True) - db.upsert_printer("v24", orca_machine="Voron", orca_process="P", orca_filament="F", cura_printer="voron24") + db.upsert_printer( + "v24", orca_machine="Voron", orca_process="P", orca_filament="F", cura_printer="voron24" + ) db.use_printer("v24") assert db.get_setting("orca_machine") == "Voron" @@ -221,9 +223,7 @@ def test_slice_cura_flag_beats_active_printer(tmp_db): db.upsert_printer("v24", cura_printer="voron24_cura") db.use_printer("v24") tool = _StubCuraTool() - pp_thing.cmd_slice( - [str(_seed_model()), "--slicer", "cura", "--printer", "prusa_mk4"], [tool] - ) + pp_thing.cmd_slice([str(_seed_model()), "--slicer", "cura", "--printer", "prusa_mk4"], [tool]) assert tool.kwargs is not None assert tool.kwargs["printer"] == "prusa_mk4" # explicit flag wins diff --git a/tests/test_slice_orca_integration.py b/tests/test_slice_orca_integration.py index a15596b..7eb462f 100644 --- a/tests/test_slice_orca_integration.py +++ b/tests/test_slice_orca_integration.py @@ -28,33 +28,54 @@ pytest.importorskip("printmcp.config", reason="PrintMCP checkout not found next to PrintPal") pytest.importorskip("printmcp.orca", reason="PrintMCP checkout has no orca module") -from printmcp.config import get_orca_paths -from printmcp.orca import orca_slice_model +from printmcp.config import get_orca_paths # noqa: E402 (after importorskip + sys.path shim) +from printmcp.orca import orca_slice_model # noqa: E402 -from printpal import db as pp_db -from printpal.commands import thing as pp_thing +from printpal import db as pp_db # noqa: E402 +from printpal.commands import thing as pp_thing # noqa: E402 def _write_cube_stl(path) -> None: """Write a valid closed 20mm binary cube STL (12 triangles).""" - V = [(0, 0, 0), (20, 0, 0), (20, 20, 0), (0, 20, 0), - (0, 0, 20), (20, 0, 20), (20, 20, 20), (0, 20, 20)] - F = [(0, 3, 2), (0, 2, 1), (4, 5, 6), (4, 6, 7), (0, 1, 5), (0, 5, 4), - (2, 3, 7), (2, 7, 6), (1, 2, 6), (1, 6, 5), (0, 4, 7), (0, 7, 3)] + vertices = [ + (0, 0, 0), + (20, 0, 0), + (20, 20, 0), + (0, 20, 0), + (0, 0, 20), + (20, 0, 20), + (20, 20, 20), + (0, 20, 20), + ] + faces = [ + (0, 3, 2), + (0, 2, 1), + (4, 5, 6), + (4, 6, 7), + (0, 1, 5), + (0, 5, 4), + (2, 3, 7), + (2, 7, 6), + (1, 2, 6), + (1, 6, 5), + (0, 4, 7), + (0, 7, 3), + ] def nrm(a, b, c): import math + ux, uy, uz = [b[i] - a[i] for i in range(3)] vx, vy, vz = [c[i] - a[i] for i in range(3)] n = (uy * vz - uz * vy, uz * vx - ux * vz, ux * vy - uy * vx) - L = math.sqrt(sum(x * x for x in n)) or 1 - return tuple(x / L for x in n) + length = math.sqrt(sum(x * x for x in n)) or 1 + return tuple(x / length for x in n) with open(path, "wb") as fh: fh.write(b"\0" * 80) - fh.write(struct.pack("