From 7b18bc4822e8c6c33fcf844d4b50915eec582bb9 Mon Sep 17 00:00:00 2001 From: Norman Hooper Date: Thu, 7 May 2026 11:49:49 -0400 Subject: [PATCH 01/10] docs: clean up references to pytest --- .gitignore | 1 - CLAUDE.md | 18 ++++++++++-------- src/sheetwright/testing/__init__.py | 5 +---- tests/fixtures/workbooks.py | 4 ++-- 4 files changed, 13 insertions(+), 15 deletions(-) diff --git a/.gitignore b/.gitignore index 3cc7ab9..94f5140 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,6 @@ __pycache__/ *.py[cod] *.egg-info/ -.pytest_cache/ .ruff_cache/ # uv / venvs diff --git a/CLAUDE.md b/CLAUDE.md index ceab49d..841dffd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,7 +3,7 @@ ## Layout - `src/sheetwright/` — library + CLI entry points -- `tests/` — pytest tests, mirroring the package layout +- `tests/` — testsweet tests, mirroring the package layout - `claude/specs/` — design specifications - `claude/plans/` — implementation plans (when written) @@ -18,27 +18,29 @@ ## Python and tooling - Python 3.11 (`.python-version`) + +`python` must be run using `uv run python ...` + - [`uv`](https://docs.astral.sh/uv/) is the project manager — use `uv sync` to install, `uv run ` to run, `uv add ` to add runtime deps, `uv add --dev ` for dev deps. Don't edit `pyproject.toml` dependency lists by hand unless you also know to update `uv.lock`. + - [`ruff`](https://docs.astral.sh/ruff/) is the formatter/linter. **Run `uv run ruff format ` before every commit.** Project style is `line-length = 79` and `quote-style = 'single'` — use single quotes in new code; ruff format will fix mixed quoting. + - [`mypy`](https://mypy.readthedocs.io/) checks type annotations. **Run `uv run mypy src/` before committing changes that add or modify type annotations.** Public functions and CLI entry points must have type hints; mypy must pass before commit. -`python` must be run using `uv run python ...` - -## Testing - -Use [testsweet](https://github.com/kaapstorm/testsweet). For links to -documentation, see its -[README.md](https://raw.githubusercontent.com/kaapstorm/testsweet/refs/heads/main/README.md). +- [testsweet](https://github.com/kaapstorm/testsweet) is the test library. + **Run `uv run python -m testsweet tests/` to verify changes.** + For links to documentation, see the testsweet + [README.md](https://raw.githubusercontent.com/kaapstorm/testsweet/refs/heads/main/README.md). ## Code style diff --git a/src/sheetwright/testing/__init__.py b/src/sheetwright/testing/__init__.py index aa91e7a..5bdcde7 100644 --- a/src/sheetwright/testing/__init__.py +++ b/src/sheetwright/testing/__init__.py @@ -1,7 +1,4 @@ -"""Test-time helpers exposed to user pytest tests.""" - -from __future__ import annotations - +"""Test-time helpers exposed to user testsweet tests.""" from sheetwright.testing.addresses import parse_address from sheetwright.testing.model import Model diff --git a/tests/fixtures/workbooks.py b/tests/fixtures/workbooks.py index 54b7e16..ccd891d 100644 --- a/tests/fixtures/workbooks.py +++ b/tests/fixtures/workbooks.py @@ -1,7 +1,7 @@ """Helpers that build small openpyxl workbooks for round-trip tests. -These are not pytest-unmagic fixtures — they are plain helpers callable -from tests. We keep them in one place so test setup stays consistent. +These are helpers callable from tests. We keep them in one place so test +setup stays consistent. """ from __future__ import annotations From 76d7d7a5bd05c097da08905efcf75d8b0f31a4c9 Mon Sep 17 00:00:00 2001 From: Norman Hooper Date: Thu, 7 May 2026 13:37:15 -0400 Subject: [PATCH 02/10] docs: clean up implemented spec --- claude/specs/2026-05-04_v1-followup-design.md | 212 ------------------ 1 file changed, 212 deletions(-) delete mode 100644 claude/specs/2026-05-04_v1-followup-design.md diff --git a/claude/specs/2026-05-04_v1-followup-design.md b/claude/specs/2026-05-04_v1-followup-design.md deleted file mode 100644 index 97ded17..0000000 --- a/claude/specs/2026-05-04_v1-followup-design.md +++ /dev/null @@ -1,212 +0,0 @@ -# sheetwright v1 — review follow-up design - -**Status:** draft -**Reference review:** [`claude/reviews/2026-05-04_v1-codebase-review.md`](../reviews/2026-05-04_v1-codebase-review.md) - -## Purpose - -Sequence the 35 findings from the v1 review into coherent work -phases. Bundle by root cause rather than by severity alone — three -of the major findings dissolve into one structural change, and most -minors fall out as side effects of those structural changes. - -## Phasing principles - -- **Security fixes ship first** and are not bundled with refactors. -- **Structural fixes precede cosmetic ones** so we don't re-touch the - same files twice. -- **Each phase is independently mergeable** with a green test suite. -- A phase is a *plan* (lives under `claude/plans/`) once it's - detailed; this document is the index. - -## Phases - -### Phase 1 — Security hardening (urgent) - -Goal: close the critical and major security findings before any -non-trusted MCP client is plausible. - -| Findings | Change | -|------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| **F1**, F6 | Treat each tool call's `project` argument as the workspace root for that call. Every other path on the same call (`xlsx`, `out_path`, `vs`, `path`, …) must resolve under the project root after `Path.resolve()`; reject otherwise with `path_outside_project`. `do_test` stops importing arbitrary paths: test discovery is constrained to `/tests/`. Preserves the documented "one server, many projects" model. | -| F7 | Add a `safe_load_workbook()` helper in `xlsx/` that wraps `openpyxl.load_workbook` with: max uncompressed size, max sheet count, max row × col, max shared-string count. All three current call-sites (reader, flatten, libreoffice) route through it. Limits configurable via `sheetwright.toml`. | -| F8 | Validate / quote SQL identifiers in `bulk._create_table`. Reject any column header that's not `[A-Za-z_][A-Za-z0-9_]*`; double-quote and escape internal `"` in the emitted DDL. | -| F19 | Add `--safe-mode --norestore --nolockcheck --nofirststartwizard --nodefault` to the LibreOffice argv in `calc/libreoffice.py`. | -| F20 | `apply_session` re-resolves the staged xlsx path against `/.sheetwright/staged/` rather than trusting the on-disk `xlsx_path` field. | -| F35 | Add a `# Trust model` block to `mcp/server.py` and a section to `docs/reference/mcp.md`: trusted operator, untrusted xlsx inputs, and the per-call project-root invariant. Spell out the security implication of the "one server, many projects" choice — a tool call's `project` arg defines the sandbox for that call only; the operator (not the server) is responsible for which project paths the client may choose, since any project on disk is reachable. Update the wiring example to reflect this. | - -Deliverables: one plan, one PR. Tests: zip-bomb and traversal -fixtures live under `tests/security/`. - -### Phase 2 — Typed command results (the structural fix) - -Goal: dissolve findings F2, F4, F16, and unblock F30 / F34. Replicates -the `stage_reimport` / `commit_staged` pattern across every command. - -**Shape:** - -```python -# commands/build_cmd.py -@dataclass(frozen=True) -class BuildResult: - xlsx_path: Path - sheets_written: int - -def run(project: Project, *, out: Path | None = None) -> BuildResult: ... -``` - -**Steps:** - -1. Define `SheetwrightError` hierarchy in `exceptions.py` with stable - string `code` attributes (`project_not_found`, `no_built_xlsx`, - `dirty_workspace`, `external_ref`, `recalc_failed`, …). Replace - the closed `MCP error code` set in `mcp/errors.py`. -2. Rewrite each `commands/*.run()` to return a typed `Result` and - raise `SheetwrightError` subclasses. Stop raising - `ClickException` from `reimport/flow.py` and `diff/loaders.py`. -3. The Click wrappers (`cli.py` group) become thin: call `run()`, - `click.echo` a human-readable rendering of the result, translate - `SheetwrightError → ClickException` at the boundary. -4. The MCP wrappers (`mcp/server.py`) call `run()` directly and - serialise the `Result` to JSON. Delete `_run_capturing`. Delete - `classify_click_error`. The exit-code-as-`has_diffs` hack in - `do_diff` goes away — `DiffResult.has_diffs` is a real field. -5. Hoist the repeated `Project.open` + error-translation block - (F16) into a single helper used by both the CLI and MCP wrappers. -6. Add a `tests/test_cli_mcp_parity.py` (F34) that asserts every - command has both surfaces and that the JSON shape of each MCP - tool matches a golden schema. - -Side effects: F30 (structured logging) becomes trivial — introduce -a `sheetwright.log` channel that the MCP wrapper routes to stderr -while the CLI keeps `click.echo` for stdout. F22 (lazy imports) -mostly disappears as cycles dissolve. - -### Phase 3 — Conditional-format polymorphism - -Goal: kill findings F3 and most of F14. - -Each `ConditionalFormat` subclass gains four classmethods/methods — -`to_yaml`, `from_yaml`, `to_xlsx`, `from_xlsx` — registered against a -single `KIND` string. The five-way isinstance ladders in -`source/yaml_sidecar.py` and `xlsx/cf_translate.py` are replaced by -a `_REGISTRY[kind].from_yaml(...)` lookup. Centralise default -constants (`'FF638EC6'`, `'3TrafficLights1'`, etc.) on the dataclass. - -Side effects: removes ~10 of the ~20 `# type: ignore` markers. - -### Phase 4 — openpyxl seam containment - -Goal: kill the rest of F14, plus F13 and F23. - -1. Add `xlsx/_compat.py` with thin typed wrappers around the bits of - openpyxl we use (`column_index_from_string`, `get_column_letter`, - `Tokenizer`, the `Color` constructor). `source/markdown.py` and - `diff/check.py` import from there, not from `openpyxl`. -2. Either ship a `xlsx/openpyxl.pyi` stub or replace the remaining - ignores with an `Any`-typed local alias and a one-line comment - explaining which openpyxl class is at the boundary. -3. Name the magic constants: `XLSX_RGB_ALPHA = 'FF'`, - `CACHE_HASH_CHUNK = 65_536`. (F23) - -### Phase 5 — Calc engine: real plug-in interface - -Goal: deliver on the README's "swappable" claim. Findings F5 and F21. - -1. Replace `evaluate(xlsx_path) -> CalcResult` with an interface that - doesn't bake in process boundaries — e.g. `evaluate(workbook: - Workbook) -> CalcResult` plus a default `LibreOfficeEngine` that - serialises to a temp xlsx internally. In-process / remote engines - become possible without reshaping the interface. -2. Replace the if/elif factory in `calc/__init__.py` with a registry - that the LibreOffice engine registers into via entry-point or an - explicit registration call from `__init__`. Configurable via - `sheetwright.toml` `[calc] engine = "libreoffice"`. -3. Add a `FakeCalcEngine` for tests so the suite no longer needs a - real LibreOffice install for the bulk of the cases. -4. Rename the temp-dir prefix `'cshs-calc-'` → `'sheetwright-calc-'` - (F21). Trivial. - -### Phase 6 — Quality refactors - -Bundled because each one is small and they all touch the same set of -files Phase 2 just rewrote. - -| Finding | Change | -|----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| F11 | Split `xlsx/reader.read_xlsx` into `_read_sheet` + `_make_cell`. | -| F12 | Extract `_format_outcome` and a `_user_test_namespace` context manager out of `commands/test_cmd.run`. | -| F15 | Delete `WorkbookManifest.__eq__`; rely on dataclass default. Add a regression test for the latent bug it was masking. | -| F17, F29 | Add `Project.built_xlsx_path` (cached) and a `cached_config` property. Five command modules drop the duplicated `built = project.build_dir / f'{cfg.name}.xlsx'`. | -| F22 | Hoist remaining lazy imports to module level after Phase 2 collapses cycles. | -| F24 | One `sheet_stem(path) -> str` helper consumed by `source/reader`, `source/writer`, `diff/check`. | -| F25 | Delete `ImportError_` and `BuildError`. | -| F26 | Promote enum-like string sets to `enum.StrEnum`: `NamedRange.scope`, `CheckIssue.kind`, CF `kind`, MCP error codes. | -| F27 | `snapshot_cmd` calls `recalc_cmd.run()` instead of re-implementing cache-or-evaluate. | -| F28 | Codebase-wide sweep: `Optional[X]` → `X | None`. Adds a ruff rule to keep it that way. | -| F31 | `_format_id` hashes `json.dumps(asdict(fmt), sort_keys=True)` rather than `repr`. | -| F32 | Replace the `update: bool` parameter on snapshot with two methods (`snapshot.run` / `snapshot.update`), or two subcommands at the CLI layer. | -| F33 | Drop the `# for mypy` assert; narrow the type at its source. | - -### Phase 7 — State integrity & tooling discipline - -| Finding | Change | -|---------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| F10 | `reimport/session.load_session`, `build_hash.read_build_hash`, `diff/check._bulk_data_issues` all distinguish "absent" from "corrupt": absent returns `None`, corrupt raises `CorruptStateError` (a `SheetwrightError`). The check command surfaces it. | -| F18 | `gitutil.has_uncommitted_changes`: add a 5s timeout, `logger.warning` on subprocess failure, `not_a_repo` short-circuit, and switch the `.is_dir()` check to one that recognises git worktrees (`git rev-parse --git-dir`). | -| F9 | Migrate `tests/` from raw `@test` + ad-hoc `@contextmanager` to pytest-unmagic `@fixture` / `@use` per CLAUDE.md. Build a small `tests/fixtures.py` module with `project`, `built_workbook`, `mcp_session`, etc. | - -F9 is the largest task by line count; suggest splitting into -sub-plans by test-file group. - -## Out of scope - -- The README's v2 list (watch-mode, semantic-diff renderer, - multi-workbook, alternative calc engines, `imports/` retention). - This document only covers v1 review fallout. - -## Suggested execution order - -1. Phase 1 (security) — week 1. -2. Phase 2 (typed results) — weeks 2–3. -3. Phase 3 (CF polymorphism) — week 3. -4. Phases 4 + 6 in parallel — week 4. -5. Phase 5 (calc engine) — week 5. -6. Phase 7 (state + tests) — week 6, with F9 spilling into 7. - -Each phase gets its own plan under `claude/plans/`, named -`YYYY-MM-DD_v1-followup-phase-N-.md`, written immediately -before that phase starts. - -## Open questions - -- **Workspace root sourcing.** Phase 1 needs a workspace-root - contract for the MCP server. Options: (a) required `--workspace` - flag on `sheetwright mcp`; (b) auto-discover from cwd at startup; - (c) per-tool argument. - - **Resolved: (c).** The documented "one server, many projects" - model (mcp.md:32) means each tool call carries its own `project` - arg, and that arg is the sandbox for that call. (a) and (b) would - silently break that contract. The `--directory` in the Claude - Desktop wiring example is `uv`'s flag, not a sheetwright concept. - Phase 1 enforces: every other path on a call must resolve under - the call's `project` root. The trust boundary stays at the - operator — they decide which project paths the client may choose. - This trade-off is documented in `docs/reference/mcp.md` (F35). - -- **Test-runner safety.** Once `do_test` is constrained to - `/tests/`, we still `exec_module` the user's code. - - **Resolved: acceptable under the trusted-operator model.** The - operator chose the project path; tests in that project run - in-process. Phase 1's `# Trust model` doc block calls this out - explicitly. Revisit if the trust model widens (e.g. multi-tenant - hosting), at which point a subprocess boundary is the path. - -- **Enum vs StrEnum vs Literal.** F26 has three viable shapes. Pick - one project-wide rather than mixing. - - **Resolved: `enum.StrEnum`** project-wide. Phase 6 standardises - `NamedRange.scope`, `CheckIssue.kind`, CF `kind`, and the new - `SheetwrightError.code` set on it. From c4f962a715d2792b733f4b1026fdd2302bc3bfdd Mon Sep 17 00:00:00 2001 From: Norman Hooper Date: Thu, 7 May 2026 13:39:22 -0400 Subject: [PATCH 03/10] docs: typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b39d348..a912815 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A toolkit that lets [Claude Code](https://claude.com/claude-code) work with spreadsheets the way it works with code: text-source files, git, TDD, diffs, and review. -`.xlsx` is treated as a build artefact compiled from text sources. You +`.xlsx` is treated as a build artifact compiled from text sources. You write Markdown tables and YAML sidecars; sheetwright produces the workbook and runs [LibreOffice Calc](https://www.libreoffice.org/) in headless mode (the default, swappable calc engine) to evaluate every From 273d4e3f781b80d8d4279afdd32af772e7f65386 Mon Sep 17 00:00:00 2001 From: Norman Hooper Date: Thu, 7 May 2026 16:24:31 -0400 Subject: [PATCH 04/10] docs: nit: reformat table --- claude/specs/2026-04-25_sheetwright-design.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/claude/specs/2026-04-25_sheetwright-design.md b/claude/specs/2026-04-25_sheetwright-design.md index 847102b..2ef5f67 100644 --- a/claude/specs/2026-04-25_sheetwright-design.md +++ b/claude/specs/2026-04-25_sheetwright-design.md @@ -127,13 +127,13 @@ draw values from it); small sheets stay fully in `.md`. ### Version control -| Versioned | Ignored | -|----------------------------------------------|------------------------------------------| -| `sheetwright.toml`, `workbook.toml` | `build/` (built xlsx) | -| `sheets/*.md`, `sheets/*.yaml` | `.sheetwright/` (built SQLite, caches) | -| `data/*.csv`, `data/_schema.sql` | | -| `tests/*.py` | | -| `imports/*.xlsx` (opt-in via `--archive`) | | +| Versioned | Ignored | +|-------------------------------------------|----------------------------------------| +| `sheetwright.toml`, `workbook.toml` | `build/` (built xlsx) | +| `sheets/*.md`, `sheets/*.yaml` | `.sheetwright/` (built SQLite, caches) | +| `data/*.csv`, `data/_schema.sql` | | +| `tests/*.py` | | +| `imports/*.xlsx` (opt-in via `--archive`) | | ## Workflows From 0b2c3474125850c2370942fc9b56951fcd8845cb Mon Sep 17 00:00:00 2001 From: Norman Hooper Date: Fri, 8 May 2026 21:21:15 -0400 Subject: [PATCH 05/10] Documentation for Windows --- README.md | 12 +- docs/README.md | 4 + docs/getting-started.md | 28 ++++ docs/reference/mcp.md | 16 ++- docs/tutorials/windows-setup.md | 222 ++++++++++++++++++++++++++++++++ 5 files changed, 276 insertions(+), 6 deletions(-) create mode 100644 docs/tutorials/windows-setup.md diff --git a/README.md b/README.md index a912815..9a59f4e 100644 --- a/README.md +++ b/README.md @@ -46,9 +46,15 @@ uv sync uv run sheetwright --help ``` -LibreOffice must be on `$PATH` for the default calc engine -(Debian/Ubuntu: `apt install libreoffice`; macOS: -`brew install --cask libreoffice`). +LibreOffice must be on `PATH` for the default calc engine. + +- Debian/Ubuntu: `apt install libreoffice` +- macOS: `brew install --cask libreoffice` +- Windows (PowerShell): + `winget install --id TheDocumentFoundation.LibreOffice` + +Windows users new to the command line should start with the +[Windows setup primer](docs/tutorials/windows-setup.md). ## Documentation diff --git a/docs/README.md b/docs/README.md index 0db47f7..5fc7aaa 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,6 +12,8 @@ pull request, regression-test in CI, and edit alongside Claude Code. - [Getting started](getting-started.md) — install, scaffold a project, edit a sheet, build, and run your first test in about ten minutes. +- [Windows setup primer](tutorials/windows-setup.md) — for readers + new to PowerShell, `winget`, and git on Windows. ## Reference @@ -27,6 +29,8 @@ pull request, regression-test in CI, and edit alongside Claude Code. ## Tutorials +- [Windows setup primer](tutorials/windows-setup.md) — WinGet, + LibreOffice, Sourcetree, and PowerShell basics. - [Greenfield project](tutorials/greenfield-project.md) — build a new model from scratch. - [Importing an existing workbook](tutorials/importing-existing.md) — diff --git a/docs/getting-started.md b/docs/getting-started.md index 7aa122c..38a77c0 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -23,6 +23,19 @@ brew install --cask libreoffice brew install python@3.11 ``` +On Windows (PowerShell, using +[WinGet](https://learn.microsoft.com/en-us/windows/package-manager/winget/)): + +```powershell +winget install --id TheDocumentFoundation.LibreOffice +winget install --id Python.Python.3.11 +winget install --id Git.Git +``` + +New to PowerShell or `winget`? Start with the [Windows setup +primer](tutorials/windows-setup.md), which covers `winget`, +LibreOffice, Sourcetree, and the PowerShell commands you'll need. + ## Install sheetwright is built on [`uv`](https://docs.astral.sh/uv/). The @@ -42,6 +55,21 @@ source .venv/bin/activate pip install sheetwright ``` +On Windows: + +```powershell +py -3.11 -m venv .venv +.\.venv\Scripts\Activate.ps1 +pip install sheetwright +``` + +If PowerShell blocks `Activate.ps1` with an execution-policy error, +allow signed local scripts for your user once with: + +```powershell +Set-ExecutionPolicy -Scope CurrentUser RemoteSigned +``` + Confirm: ```bash diff --git a/docs/reference/mcp.md b/docs/reference/mcp.md index fec1f88..11eedd6 100644 --- a/docs/reference/mcp.md +++ b/docs/reference/mcp.md @@ -8,9 +8,15 @@ the client closes the connection. ## Wiring Most MCP clients launch `sheetwright mcp` as a subprocess and route -MCP traffic over stdin/stdout. For Claude Desktop, add to -`~/Library/Application Support/Claude/claude_desktop_config.json` -(macOS) or the equivalent on your platform: +MCP traffic over stdin/stdout. For Claude Desktop, edit the config +file for your platform: + +- macOS: + `~/Library/Application Support/Claude/claude_desktop_config.json` +- Linux: `~/.config/Claude/claude_desktop_config.json` +- Windows: `%APPDATA%\Claude\claude_desktop_config.json` + +Add an entry like: ```json { @@ -23,6 +29,10 @@ MCP traffic over stdin/stdout. For Claude Desktop, add to } ``` +On Windows, use a full Windows path (and double the backslashes in +JSON), for example +`"C:\\Users\\you\\my-model"`. + For a generic stdio MCP client: ```bash diff --git a/docs/tutorials/windows-setup.md b/docs/tutorials/windows-setup.md new file mode 100644 index 0000000..294a2a9 --- /dev/null +++ b/docs/tutorials/windows-setup.md @@ -0,0 +1,222 @@ +# Windows setup primer + +A guided walkthrough for getting a sheetwright work environment +running on Windows 10 or 11. If you are comfortable on the command +line and just want install commands, jump to [getting +started](../getting-started.md) — it has Windows snippets alongside +the macOS and Linux ones. + +This primer is for readers who do most of their work in Excel or +File Explorer and have not used PowerShell, `winget`, or git from a +terminal before. By the end you will have: + +- LibreOffice installed (sheetwright's default calc engine). +- Sourcetree installed (a graphical git client). +- A project folder ready for `sheetwright init`. +- Enough PowerShell to run sheetwright commands without guessing. + +## What is WinGet, and why use it? + +[WinGet](https://learn.microsoft.com/en-us/windows/package-manager/winget/) +is the **Windows Package Manager**. It ships with Windows 11 and +recent Windows 10 builds as the `winget` command. Think of it as the +Windows equivalent of `apt` on Ubuntu or `brew` on macOS: a single +command that downloads, installs, and updates software from a curated +catalogue. + +Why bother instead of clicking through installers? + +- **Reproducible.** `winget install ` does the same thing on + every machine. You can paste install commands into onboarding docs + and trust them. +- **Updatable.** `winget upgrade --all` pulls the latest versions of + everything you installed through it. No more hunting for "Check + for updates" menus. +- **Scriptable.** The same commands work in a setup script, in a CI + job, or on a freshly-imaged laptop. +- **Trustworthy sources.** Packages come from publishers' own + installers, not random download mirrors. + +To check that `winget` works, open **PowerShell** (press the Windows +key, type `powershell`, press Enter) and run: + +```powershell +winget --version +``` + +If you see a version like `v1.7.10661`, you are ready. If not, +install **App Installer** from the Microsoft Store, then reopen +PowerShell. + +## Install LibreOffice with WinGet + +sheetwright uses LibreOffice's headless mode (`soffice`) to evaluate +spreadsheet formulas. Install it with: + +```powershell +winget install --id TheDocumentFoundation.LibreOffice +``` + +WinGet will download LibreOffice, run its installer silently, and +add it to your system. To confirm: + +```powershell +soffice --version +``` + +If PowerShell reports `soffice` is not recognised, close and reopen +PowerShell so it picks up the updated `PATH`. If it still cannot +find it, add LibreOffice's program folder to your `PATH` — typically +`C:\Program Files\LibreOffice\program` (see [PowerShell +basics](#powershell-basics-for-using-sheetwright) below). + +## Install Sourcetree with WinGet + +[Sourcetree](https://www.sourcetreeapp.com/) is a free graphical git +client from Atlassian. sheetwright uses git for the re-import flow +(it checks for uncommitted source changes before overwriting your +files), and Sourcetree gives you a visual way to stage, commit, and +review diffs without learning git's command line first. + +```powershell +winget install --id Atlassian.Sourcetree +``` + +Launch Sourcetree from the Start menu. On first run it will: + +1. Ask you to sign in or skip — skip is fine for local work. +2. Offer to install **Git** if it is not already on the system. + Accept; sheetwright needs `git` available too. +3. Ask for your name and email. Use the same name and email you + want on your commits. + +You can drive sheetwright projects entirely from Sourcetree's +**Stage**, **Commit**, **Push**, and **Pull** buttons. The "Show +diff" pane is especially useful for reviewing changes to your +Markdown sheet sources before committing. + +## Create a folder for your project + +PowerShell uses `\` (backslash) for paths, but forward slashes work +in most commands too. Pick a location you'll remember — your user +folder is a sensible default: + +```powershell +cd $HOME +mkdir my-model +cd my-model +``` + +`$HOME` expands to `C:\Users\`. After the three +commands above your prompt should look something like: + +``` +PS C:\Users\you\my-model> +``` + +This is now your **project folder**. Every sheetwright command +you run should be from inside it. + +If you want git tracking from the start, initialise the repo with +Sourcetree: + +1. **File → Clone / New… → Create**. +2. Set **Destination Path** to `C:\Users\you\my-model`. +3. Click **Create**. + +Sourcetree will turn the folder into a git repo. From here on, +every `sheetwright build` or edit will show up in Sourcetree's +**File Status** view as something you can stage and commit. + +## PowerShell basics for using sheetwright + +You don't need to become a PowerShell expert. The handful of +commands below cover everything sheetwright's docs assume. + +### Navigating + +| Task | PowerShell | +|----------------------------------|---------------------------| +| Show the current folder | `pwd` (or `Get-Location`) | +| List files in the current folder | `ls` (or `dir`) | +| Move into a folder | `cd path\to\folder` | +| Move up one folder | `cd ..` | +| Go to your home folder | `cd $HOME` | + +Tab-completion works: type the first few letters of a folder or +file name and press **Tab**. + +### Running sheetwright + +sheetwright is invoked through `uv` (see the [getting started +guide](../getting-started.md) for installing `uv`). From inside +your project folder: + +```powershell +uv run sheetwright --version +uv run sheetwright init . +uv run sheetwright build +uv run sheetwright test +``` + +The `.` in `init .` means "scaffold into the current folder." + +### Inspecting and editing files + +Open the current folder in **File Explorer**: + +```powershell +explorer . +``` + +Open a file in your default editor (or pass a specific app): + +```powershell +notepad sheetwright.toml +``` + +If you have **VS Code** installed via WinGet +(`winget install Microsoft.VisualStudioCode`), you can open the +project in it with: + +```powershell +code . +``` + +### Adding a folder to PATH (only if needed) + +If a command like `soffice` or `git` "is not recognised", you may +need to add its install folder to your `PATH`. The least-surprising +way is the **System Properties** GUI: + +1. Press **Windows key**, type `environment variables`, press Enter. +2. Click **Environment Variables…**. +3. Under **User variables**, select **Path** and click **Edit…**. +4. Click **New**, paste the folder (e.g. + `C:\Program Files\LibreOffice\program`), click **OK** on each + dialog. +5. Close and reopen PowerShell. + +For a one-off session you can add to `PATH` from PowerShell: + +```powershell +$env:Path += ';C:\Program Files\LibreOffice\program' +``` + +This only affects the current PowerShell window. + +### Quoting paths with spaces + +If a path contains spaces, wrap it in single quotes: + +```powershell +cd 'C:\Users\you\OneDrive - Acme\my-model' +``` + +## What's next? + +- [Getting started](../getting-started.md) — install `uv`, scaffold + a project, build, and run your first test. The Windows snippets + there pick up where this primer leaves off. +- [Greenfield project tutorial](greenfield-project.md) — build a + full model from scratch. From 8f86d4852175f3059f3bd074c037cd5849bed589 Mon Sep 17 00:00:00 2001 From: Norman Hooper Date: Fri, 8 May 2026 21:57:10 -0400 Subject: [PATCH 06/10] Add GitHub workflow --- .github/workflows/test.yml | 63 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..373d3d7 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,63 @@ +name: Test + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + name: Test on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Set up Python + run: uv python install 3.11 + + - name: Install LibreOffice (Ubuntu) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libreoffice + + - name: Install LibreOffice (macOS) + if: runner.os == 'macOS' + run: brew install --cask libreoffice + + - name: Install LibreOffice (Windows) + if: runner.os == 'Windows' + run: choco install libreoffice-still -y --no-progress + + - name: Add LibreOffice to PATH (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: echo "C:\Program Files\LibreOffice\program" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + + - name: Confirm soffice is available + run: soffice --version + + - name: Install dependencies + run: uv sync --all-extras + + - name: Lint (ruff) + run: uv run ruff check src tests + + - name: Format check (ruff) + run: uv run ruff format --check src tests + + - name: Type check (mypy) + run: uv run mypy src/ + + - name: Run tests + run: uv run python -m testsweet tests/ From c08a7165f1bd0bcc0cd90ff2d45be84bb9ae34dd Mon Sep 17 00:00:00 2001 From: Norman Hooper Date: Fri, 8 May 2026 22:13:08 -0400 Subject: [PATCH 07/10] fix: remove unused imports and reformat to satisfy ruff The new GitHub Actions workflow runs ruff check and ruff format --check, which surfaced four unused imports and one unformatted file pre-existing on main. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/sheetwright/commands/recalc_cmd.py | 1 - src/sheetwright/testing/__init__.py | 1 + src/sheetwright/xlsx/flatten.py | 1 - tests/security/test_bulk_identifier_injection.py | 2 +- tests/security/test_path_containment.py | 1 - 5 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/sheetwright/commands/recalc_cmd.py b/src/sheetwright/commands/recalc_cmd.py index 6796430..e68a9cd 100644 --- a/src/sheetwright/commands/recalc_cmd.py +++ b/src/sheetwright/commands/recalc_cmd.py @@ -2,7 +2,6 @@ from __future__ import annotations -from pathlib import Path import click diff --git a/src/sheetwright/testing/__init__.py b/src/sheetwright/testing/__init__.py index 5bdcde7..53273f5 100644 --- a/src/sheetwright/testing/__init__.py +++ b/src/sheetwright/testing/__init__.py @@ -1,4 +1,5 @@ """Test-time helpers exposed to user testsweet tests.""" + from sheetwright.testing.addresses import parse_address from sheetwright.testing.model import Model diff --git a/src/sheetwright/xlsx/flatten.py b/src/sheetwright/xlsx/flatten.py index b83b213..78fcfec 100644 --- a/src/sheetwright/xlsx/flatten.py +++ b/src/sheetwright/xlsx/flatten.py @@ -17,7 +17,6 @@ from pathlib import Path from typing import Tuple -import openpyxl from openpyxl.formula.tokenizer import Tokenizer from sheetwright.model.cell import Cell diff --git a/tests/security/test_bulk_identifier_injection.py b/tests/security/test_bulk_identifier_injection.py index fc3b82c..c487523 100644 --- a/tests/security/test_bulk_identifier_injection.py +++ b/tests/security/test_bulk_identifier_injection.py @@ -8,7 +8,7 @@ from testsweet import catch_exceptions, test -from sheetwright.bulk import build_bulk_cache, table_name_for +from sheetwright.bulk import build_bulk_cache from sheetwright.exceptions import BulkInvalidIdentifierError diff --git a/tests/security/test_path_containment.py b/tests/security/test_path_containment.py index 5105f34..dceb88d 100644 --- a/tests/security/test_path_containment.py +++ b/tests/security/test_path_containment.py @@ -2,7 +2,6 @@ from __future__ import annotations -import os import tempfile from pathlib import Path From 33e5c918b5146d7445096fa6631d78b445df1bcf Mon Sep 17 00:00:00 2001 From: Norman Hooper Date: Sat, 9 May 2026 10:58:26 -0400 Subject: [PATCH 08/10] fix: resolve macOS path test test_path_containment: on macOS, tempfile dirs live under /var which is a symlink to /private/var, so resolve_under returns a resolved path that doesn't equal the unresolved root. Match the sibling deeply-nested test by calling root.resolve() in the assertion. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/security/test_path_containment.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/security/test_path_containment.py b/tests/security/test_path_containment.py index dceb88d..b42da9a 100644 --- a/tests/security/test_path_containment.py +++ b/tests/security/test_path_containment.py @@ -88,7 +88,7 @@ def non_existent_path_under_root_is_accepted(): root = Path(td) # 'newdir' does not exist; the function must handle this gracefully. result = resolve_under(root, 'newdir/newfile.xlsx') - assert result == root / 'newdir' / 'newfile.xlsx' + assert result == root.resolve() / 'newdir' / 'newfile.xlsx' assert not result.exists() From 1910b655431a5fadb8e40218ca5714fefd8cf4b8 Mon Sep 17 00:00:00 2001 From: Norman Hooper Date: Sat, 9 May 2026 10:15:53 -0400 Subject: [PATCH 09/10] fix: build a valid file URI for soffice's UserInstallation env var MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous f'file://{profile}' produced a well-formed URL only on POSIX, where the path starts with '/' and yields 'file:///tmp/...'. On Windows the same expression produced 'file://D:\\a\\...', which soffice parses as a host of 'D:' followed by a backslash-laden path — never resolves, so soffice hangs waiting for a profile that will never exist. pathlib's Path.as_uri() emits the platform-correct form: POSIX: file:///tmp/foo Windows: file:///D:/a/foo Co-Authored-By: Claude Opus 4.7 (1M context) --- src/sheetwright/calc/libreoffice.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sheetwright/calc/libreoffice.py b/src/sheetwright/calc/libreoffice.py index 0ca4cdf..1bc6792 100644 --- a/src/sheetwright/calc/libreoffice.py +++ b/src/sheetwright/calc/libreoffice.py @@ -51,7 +51,7 @@ def evaluate( '--nofirststartwizard', '--nodefault', '--calc', - f'-env:UserInstallation=file://{profile}', + f'-env:UserInstallation={profile.as_uri()}', '--convert-to', 'xlsx', '--outdir', From ad2ca09ee408580ec3a2243948fe48cd7dac9906 Mon Sep 17 00:00:00 2001 From: Norman Hooper Date: Sat, 9 May 2026 10:44:21 -0400 Subject: [PATCH 10/10] fix: close file handles so TemporaryDirectory cleanup works on Windows On Windows, unlinking an open file fails with WinError 32 ("The process cannot access the file because it is being used by another process"). On POSIX it silently succeeds, so leaks went unnoticed. TemporaryDirectory cleanup walks the tree and unlinks, so any unclosed handle within propagates as a PermissionError. Production fixes: - LibreOfficeEngine._read_calculated: close the workbook in a finally block. read_only=True keeps the underlying zip handle open until close() is called, so the soffice tempdir could not be cleaned up on Windows. Cascade fix for ~10 downstream tests (recalc, snapshot, testing.Model, mcp tools, end-to-end). - safe_load_workbook: close the workbook if _stream_cell_count raises, so XlsxTooLargeError doesn't leak the handle. Test fixes: - test_safe_load_workbook streaming_cell_count_passes_under_cap: close the wb before exiting the tempdir context. - test_bulk build_loads_csv_into_sqlite: close the sqlite connection before exiting the tempdir context. - test_mcp_cmd._read_one_response: replace select.select() with a thread + queue. select.select() on Windows only works on sockets, not on subprocess stdout pipes (WinError 10038). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/sheetwright/calc/libreoffice.py | 23 +++++++++++--------- src/sheetwright/xlsx/safe_load.py | 6 +++++- tests/security/test_safe_load_workbook.py | 5 ++++- tests/test_bulk.py | 9 +++++--- tests/test_mcp_cmd.py | 26 ++++++++++++++--------- 5 files changed, 44 insertions(+), 25 deletions(-) diff --git a/src/sheetwright/calc/libreoffice.py b/src/sheetwright/calc/libreoffice.py index 1bc6792..9d04bfa 100644 --- a/src/sheetwright/calc/libreoffice.py +++ b/src/sheetwright/calc/libreoffice.py @@ -97,13 +97,16 @@ def evaluate( def _read_calculated(xlsx_path: Path, limits: SecurityLimits) -> CalcResult: wb = safe_load_workbook(xlsx_path, limits, data_only=True, read_only=True) - out: CalcResult = {} - for ws in wb.worksheets: - sheet: Dict[str, CellValue] = {} - for row in ws.iter_rows(): - for cell in row: - if cell.value is None: - continue - sheet[cell.coordinate] = cast(CellValue, cell.value) - out[ws.title] = sheet - return out + try: + out: CalcResult = {} + for ws in wb.worksheets: + sheet: Dict[str, CellValue] = {} + for row in ws.iter_rows(): + for cell in row: + if cell.value is None: + continue + sheet[cell.coordinate] = cast(CellValue, cell.value) + out[ws.title] = sheet + return out + finally: + wb.close() diff --git a/src/sheetwright/xlsx/safe_load.py b/src/sheetwright/xlsx/safe_load.py index 2841eba..2dc4b6e 100644 --- a/src/sheetwright/xlsx/safe_load.py +++ b/src/sheetwright/xlsx/safe_load.py @@ -27,7 +27,11 @@ def safe_load_workbook( _check_zip(path, limits) wb = openpyxl.load_workbook(path, data_only=data_only, read_only=read_only) if read_only: - _stream_cell_count(wb, limits) + try: + _stream_cell_count(wb, limits) + except BaseException: + wb.close() + raise return wb diff --git a/tests/security/test_safe_load_workbook.py b/tests/security/test_safe_load_workbook.py index 80f1f22..33c3b5e 100644 --- a/tests/security/test_safe_load_workbook.py +++ b/tests/security/test_safe_load_workbook.py @@ -133,7 +133,10 @@ def streaming_cell_count_passes_under_cap(): _write_xlsx_with_cells(p, 100) limits = _limits(max_xlsx_cells_per_sheet=200) wb = safe_load_workbook(p, limits, read_only=True) - assert wb is not None + try: + assert wb is not None + finally: + wb.close() # --------------------------------------------------------------------------- diff --git a/tests/test_bulk.py b/tests/test_bulk.py index 014c5c4..39db846 100644 --- a/tests/test_bulk.py +++ b/tests/test_bulk.py @@ -36,9 +36,12 @@ def build_loads_csv_into_sqlite(): assert db_path.exists() conn = sqlite3.connect(db_path) - rows = conn.execute( - 'SELECT year, value FROM series ORDER BY year' - ).fetchall() + try: + rows = conn.execute( + 'SELECT year, value FROM series ORDER BY year' + ).fetchall() + finally: + conn.close() assert rows == [ ('2020', '1.0'), ('2021', '2.0'), diff --git a/tests/test_mcp_cmd.py b/tests/test_mcp_cmd.py index 786ccbe..1c7b213 100644 --- a/tests/test_mcp_cmd.py +++ b/tests/test_mcp_cmd.py @@ -1,8 +1,8 @@ import json -import select +import queue import subprocess import sys -import time +import threading from testsweet import test @@ -30,14 +30,20 @@ def mcp_subcommand_help_describes_stdio(): def _read_one_response(stream, timeout: float) -> str: - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - ready, _, _ = select.select([stream], [], [], 0.1) - if ready: - line = stream.readline() - if line: - return line - raise TimeoutError('no MCP response within timeout') + q: queue.Queue = queue.Queue() + + def _reader(): + line = stream.readline() + q.put(line) + + threading.Thread(target=_reader, daemon=True).start() + try: + line = q.get(timeout=timeout) + except queue.Empty: + raise TimeoutError('no MCP response within timeout') from None + if not line: + raise TimeoutError('MCP stream closed without a response') + return line @test