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/ 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/README.md b/README.md index b39d348..9a59f4e 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 @@ -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/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 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. 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. diff --git a/src/sheetwright/calc/libreoffice.py b/src/sheetwright/calc/libreoffice.py index 0ca4cdf..9d04bfa 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', @@ -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/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 aa91e7a..53273f5 100644 --- a/src/sheetwright/testing/__init__.py +++ b/src/sheetwright/testing/__init__.py @@ -1,6 +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/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/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/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 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..b42da9a 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 @@ -89,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() 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