Skip to content

Commit a4e61d2

Browse files
committed
Merge branch 'feat/path-based-export' into dev
2 parents b25485b + 2eb7894 commit a4e61d2

23 files changed

Lines changed: 1146 additions & 25 deletions

‎README.md‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
# Wherewolf
22

3-
<img src="https://raw.githubusercontent.com/beallio/wherewolf/main/src/wherewolf/assets/img/wherewolf_banner.png?cacheBuster=17" width="100%">
3+
<img src="https://raw.githubusercontent.com/beallio/wherewolf/main/src/wherewolf/assets/img/wherewolf_banner.png?cacheBuster=18" width="100%">
44

5-
[![CI](https://github.com/beallio/wherewolf/actions/workflows/ci.yml/badge.svg?cacheBuster=17)](https://github.com/beallio/wherewolf/actions/workflows/ci.yml)
6-
[![PyPI version](https://img.shields.io/pypi/v/wherewolf.svg?cacheBuster=17)](https://pypi.org/project/wherewolf/)
7-
[![License: GPL-3.0-only](https://img.shields.io/badge/License-GPL--3.0--only-blue.svg?cacheBuster=17)](https://www.gnu.org/licenses/gpl-3.0.html)
5+
[![CI](https://github.com/beallio/wherewolf/actions/workflows/ci.yml/badge.svg?cacheBuster=18)](https://github.com/beallio/wherewolf/actions/workflows/ci.yml)
6+
[![PyPI version](https://img.shields.io/pypi/v/wherewolf.svg?cacheBuster=18)](https://pypi.org/project/wherewolf/)
7+
[![License: GPL-3.0-only](https://img.shields.io/badge/License-GPL--3.0--only-blue.svg?cacheBuster=18)](https://www.gnu.org/licenses/gpl-3.0.html)
88

99
A production-grade, local SQL workbench for querying files (CSV, Parquet, JSON) using DuckDB or Spark.
1010

@@ -24,10 +24,10 @@ A production-grade, local SQL workbench for querying files (CSV, Parquet, JSON)
2424
desktop selection.
2525
- **Persistent Desktop Preferences:** Window geometry, dock layout, splitter proportions, editor
2626
font size, recent dataset directory, and completion preferences survive desktop restarts.
27-
- **Export:** Download query results as CSV, Excel, or Parquet. DataFrame handling and exports are Polars-based. When the preview is truncated, use **Prepare full export** to re-run the query without a row limit and download the entire result set.
27+
- **Export:** The desktop shell exports the preview (bounded by the preview limit) to CSV, XLSX, or Parquet. Full CSV and Parquet export re-executes the captured query and streams through DuckDB directly to disk; it does not materialize the complete result in Python. Full XLSX is intentionally limited to 100,000 rows because XLSX has no streaming writer; choose CSV or Parquet for larger results.
2828
- **Execution Metrics:** Tracks row count, status, and execution time in the status bar and Messages panel.
2929

30-
![Wherewolf Screenshot](https://raw.githubusercontent.com/beallio/wherewolf/main/src/wherewolf/assets/img/screenshot.png?cacheBuster=17)
30+
![Wherewolf Screenshot](https://raw.githubusercontent.com/beallio/wherewolf/main/src/wherewolf/assets/img/screenshot.png?cacheBuster=18)
3131

3232
## Installation
3333

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# Path-based export implementation session
2+
3+
Date: 2026-08-01
4+
5+
## Objective
6+
7+
Implement Phase 12 path-based export from
8+
`docs/plans/2026-08-01_path-based-export.md`.
9+
10+
## Baseline
11+
12+
Base commit: `b25485b` (`docs(plans): scope phase 12 path-based export`).
13+
14+
- Python 3.14: `334 passed, 1 skipped, 1 warning in 20.74s` from
15+
`./run.sh uv run pytest -q`.
16+
- Python 3.12: the required `./run.sh uv run --python 3.12 pytest -q --no-cov`
17+
command exited successfully in the baseline batch; its output was truncated by the
18+
command capture before the tally could be recorded. Exact tally: not measured.
19+
- The development environment was restored with
20+
`./run.sh uv sync --all-extras --dev --python 3.14`.
21+
22+
## Decision record
23+
24+
The selected format controls the suffix; a mismatched suffix is replaced. Writes use a sibling
25+
temporary file followed by `os.replace`, so failed writes preserve an existing destination.
26+
Selection order is shared with the clipboard path. Full XLSX is capped at 100,000 rows because
27+
the available XLSX writer is not streaming; source mutations warn but do not block a requested
28+
export.
29+
30+
## Measured results
31+
32+
- Targeted export tests: `38 passed in 1.42s` (Python 3.14).
33+
- Full quality-gate run after implementation: `346 passed, 1 skipped, 1 warning in 20.58s`
34+
(Python 3.14); Ruff formatting and `ty check src/` passed.
35+
- Python 3.12 verification: `346 passed, 1 skipped, 1 warning in 14.32s`; the shared
36+
development environment was then restored to Python 3.14.
37+
- Not measured: mutation checks V8, two 25-run native-crash batches V11, a real-window manual
38+
export, multi-gigabyte memory use, Spark export, macOS and Windows dialogs. Full streaming is
39+
structurally exercised by DuckDB COPY output tests, not a large-scale memory measurement.
40+
41+
## Review round 01 resolution
42+
43+
- Added a request-scoped DuckDB connection spy that requires `COPY ... TO` and traps
44+
`.pl()`, `.arrow()`, `.fetchall()` and `.df()` on the full-export path.
45+
- Export workers now wait until their cancellation handle reaches the controller before export
46+
work starts. Cancellation tests preserve an existing destination byte-for-byte, leave no
47+
temporary file, surface failures as terminal results, and make cancellation after completion
48+
safe.
49+
- Moved the visual selection rules into `services/selection.py`; both clipboard serialization and
50+
preview export use that one implementation. The moved/hidden/discontiguous selection test is
51+
in `tests/test_selection.py`.
52+
- The required memory search returned no indexed claims or claim IDs: the project memory import is
53+
still pending. No historical claim informed this round; implementation was revalidated from the
54+
current repository and committed plan.
55+
56+
### V8 mutation checks
57+
58+
Each mutation was applied to a committed tree, confirmed by a non-empty `git diff`, grepped with
59+
`--color=never`, tested, and reverted before the next mutation.
60+
61+
1. Replaced `COPY` with `con.sql(...).pl()` plus Polars writes: `tests/test_full_export.py::test_full_export_issues_copy_without_materialising_result` failed for both `csv` and `parquet` with the `.pl()` materialisation trap.
62+
2. Wrote directly to the destination: `tests/test_export_destination.py::test_atomic_writer_preserves_existing_bytes_and_removes_temp` failed because `b"partial"` replaced `b"original"`.
63+
3. Ignored visual column mapping: `tests/test_selection.py::test_selected_frame_uses_moved_visible_columns_for_discontiguous_cells` failed with `['a', 'hidden_b']` instead of `['c', 'a']`.
64+
4. Skipped extension normalisation: `tests/test_export_destination.py::test_destination_normalisation_and_filter_are_format_driven` failed with `out` instead of `out.csv`.
65+
5. Left the temporary file behind on error: `tests/test_export_destination.py::test_atomic_writer_preserves_existing_bytes_and_removes_temp` failed on the remaining `.out.csv.*` file.
66+
6. Treated cancelled save-dialog output as a path: `tests/test_file_dialog_service.py::test_qt_export_dialog_cancellation_returns_none_without_creating_destination` failed with `ValueError` for `Path('.')`. This was re-run after committing the new cancellation test.
67+
68+
### Final verification
69+
70+
- Python 3.14: `352 passed, 1 skipped, 1 warning in 23.68s` from the final quality-gate suite.
71+
- Python 3.12: `352 passed, 1 skipped, 1 warning in 14.65s`; restored afterwards with `./run.sh uv sync --all-extras --dev --python 3.14`.
72+
- `scripts/orchestration/run-quality-gates`: passed; review-note deletion check passed; protected
73+
Streamlit/export diff against `dev` was empty; V9 reported `OK: none`.
74+
- V11: `scripts/check_flake.sh 25` passed twice with `0 native crashes in 25 runs`; the final
75+
per-run logs are preserved at `/tmp/wherewolf/flake-guard-run-1.txt` and
76+
`/tmp/wherewolf/flake-guard-run-2.txt`.
77+
- Still not measured: a real-window manual export, multi-gigabyte memory use, Spark export, and
78+
macOS/Windows dialog behavior. Streaming remains structurally verified, not memory-measured.
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
# Review — path-based-export (round 01)
2+
3+
Branch: `feat/path-based-export` @ `4857368`
4+
Reviewed against: `docs/plans/2026-08-01_path-based-export.md`
5+
6+
## Verdict
7+
8+
CHANGES_REQUESTED — **the exit criterion is unguarded.** The implementation streams correctly
9+
today; nothing stops it regressing tomorrow, and I proved that rather than inferring it.
10+
11+
## What you did well
12+
13+
- **The streaming implementation is right.** `COPY (<executable_sql>) TO '<temp>' (FORMAT ...)`
14+
on the request-scoped connection, wrapped in the atomic writer. That is exactly the design
15+
the plan asked for.
16+
- **The atomic write is correct and genuinely tested** — temp sibling, `os.replace` only on
17+
success, cleanup in `finally`, with
18+
`test_atomic_writer_preserves_existing_bytes_and_removes_temp` asserting the original's
19+
**bytes** survive a failed write. V5 is satisfied.
20+
- **`export/` and the whole Streamlit path are untouched.** The diff is empty. You wrote new
21+
path-based code rather than extending the byte-based `Exporter`, as required.
22+
- **`ExportController.shutdown()` is wired into `closeEvent`** — the crash-safety pattern this
23+
project learned the hard way.
24+
- **The session log says "not measured"** for the V8 mutations and the V11 crash batches
25+
instead of inventing results. That is the recording rule working under pressure, and it is
26+
why I knew exactly where to look. Keep doing this.
27+
28+
### My measurements
29+
30+
| check | result |
31+
|---|---|
32+
| suite on **3.14** | 346 passed, 1 skipped |
33+
| suite on **3.12** | 346 passed, 1 skipped — identical |
34+
| `run-quality-gates` | pass |
35+
| **V9** 3.14-only syntax | none |
36+
| **V2** Streamlit + `export/` diff | empty |
37+
| **V11** crash gate (25 of 50 so far) | 0 crashes; second batch running |
38+
| **V8 mutation 1 (materialise instead of stream)** | **DID NOT BITE — see H1** |
39+
40+
## Required changes
41+
42+
### H1. Nothing detects a regression from streaming to materialising
43+
44+
This is the exit criterion: *"full DuckDB CSV/Parquet export does not materialize the entire
45+
result as a Polars DataFrame plus bytes."*
46+
47+
I replaced the `COPY` call with the thing the phase exists to prevent:
48+
49+
```python
50+
def copy_to(path: Path) -> None:
51+
frame = con.sql(request.executable_sql).pl() # materialise everything
52+
frame.write_csv(path) if fmt is ExportFormat.CSV else frame.write_parquet(path)
53+
```
54+
55+
**The entire suite passed — 346 passed, 1 skipped.** Not one test noticed.
56+
57+
`tests/test_full_export.py` has two tests and both only inspect the **output file**. The plan
58+
warned about precisely this: *"a test that only inspects the output file cannot distinguish
59+
streaming from materialising."* The output is byte-identical either way — that is the whole
60+
problem.
61+
62+
**Fix (V4 as specified):** spy on the request-scoped connection and assert **both**:
63+
64+
1. a `COPY ... TO` statement is issued for the full-export path; and
65+
2. **no** materialisation call — `.pl()`, `.arrow()`, `.fetchall()`, `.df()` — occurs on it.
66+
67+
Keep exporting **more rows than `preview_limit`** so a preview-shaped result cannot masquerade
68+
as a full one. Then re-apply the mutation above and confirm the new test **FAILS**. Paste the
69+
failing node id.
70+
71+
### H2. Cancellation is unverified
72+
73+
`tests/test_export_controller.py` contains one test (emits one terminal result). Task 10 and V6
74+
are uncovered: cancelling mid-export must leave **no partial destination file**, **no temp
75+
file**, and an existing destination **untouched**; cancelling a finished export must be safe.
76+
77+
Given a half-written export is a user-visible data hazard, this needs a real test, not an
78+
inspection. Task 9's other two Red cases are also missing — the handle published **before**
79+
work starts, and a failure surfacing as a failed export rather than an exception.
80+
81+
### H3. The selection logic was duplicated, not reused
82+
83+
`src/wherewolf/selection.py` is a **second** implementation of visual-column-order selection.
84+
`desktop/clipboard_serializers.py` still has its own. The plan was explicit:
85+
86+
> **Reuse that logic; do not write a second implementation that can drift.** … **Do not
87+
> duplicate it** — if it needs to be shared, extract it once and have both call sites use it.
88+
89+
Two copies of "visual order, hidden columns excluded, discontiguous rule" will drift, and when
90+
they do, **copy and export will silently disagree about the same selection** — the kind of bug
91+
users report as "the export is wrong" with no error anywhere.
92+
93+
Extract once and route both call sites through it. `tests/test_selection.py` currently holds a
94+
single test; whichever module survives needs the full set — moved columns, hidden columns,
95+
discontiguous selection.
96+
97+
### H4. Run the V8 mutations
98+
99+
The log records them as not measured, which is honest. Now run them, and record the node id you
100+
actually observed for each. Mutation 1 is H1 above; I have run it and it does not bite, so that
101+
one is already answered — fix the test, then confirm it fails.
102+
103+
### H5. One commit for thirteen tasks
104+
105+
The plan specifies one commit per task, and the round produced two: a baseline and a single
106+
`feat(export): add path-based desktop exports` carrying everything.
107+
108+
I am **not** asking you to rewrite history. Going forward in this phase, commit per task. The
109+
granularity is what makes a failure bisectable, and it is the reason the plan is written as
110+
discrete tasks rather than a description of the finished state.
111+
112+
## Delegate the low-level work to your subagents
113+
114+
You have seven read-only `agent-memory` subagents available
115+
(`~/.codex/agents/*.toml`), and the MCP server is declared for this project with
116+
`--tool-profile full`. Use them and **surface what they return to me** rather than acting on it
117+
silently:
118+
119+
- **`memory_researcher`** — before you start this round, ask it for prior constraints, decisions
120+
and **failed approaches** relevant to export, streaming, atomic writes and Qt worker
121+
lifetime. Report the claim IDs of anything consequential in the session log.
122+
- **`memory_evidence_reviewer`** — if a remembered claim would change what you build, audit it
123+
before relying on it, and report what is supported, contradicted or stale.
124+
125+
Treat memory as **historical evidence, not current truth** — its own instructions say to
126+
revalidate drift-prone claims against the repository, which matches this project's rule that a
127+
claim is something to verify rather than trust. Do **not** ask `memory_curator` or
128+
`memory_lifecycle_manager` to write anything; nothing in this round authorizes a memory
129+
mutation.
130+
131+
## Verification before marking complete
132+
133+
- The V4 streaming spy, plus the mutation re-applied and its **failing** node id.
134+
- Cancellation tests per H2.
135+
- Single shared selection implementation per H3, with the full test set.
136+
- All six V8 mutations with observed node ids, `--color=no`, mutation-applied check.
137+
- `./run.sh uv run pytest -q` on 3.14 and `--python 3.12` — record both, then restore with
138+
`./run.sh uv sync --all-extras --dev --python 3.14`.
139+
- `scripts/orchestration/run-quality-gates` → exit 0.
140+
- `git status --short` → prints nothing.
141+
- **V11**: I am measuring 50 runs myself this round; do not re-run it unless you change
142+
`closeEvent` or worker lifetime.
143+
144+
## Constraints
145+
146+
Do not remove `timid = true`. Do not disable coverage. Do not skip, delete or xfail tests. Do
147+
not modify `export/exporter.py`, `DuckDBEngine`, or any Streamlit path. Do not touch `main`. Do
148+
not bump the package version.
149+
150+
## Deferred — correctly recorded by you
151+
152+
No human has exported from a real window; all Qt tests are offscreen. **Streaming is verified
153+
structurally, not by a memory measurement** — no multi-gigabyte export was performed, and the
154+
log says so, which is the right way to state it. Spark export unverified. macOS and Windows
155+
dialogs unverified.
156+
157+
STATUS: CHANGES_REQUESTED
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# Review — path-based-export (round 02)
2+
3+
Branch: `feat/path-based-export` @ `b9b832c`
4+
Reviewed against: `docs/plans/2026-08-01_path-based-export.md` and review 01
5+
6+
## Verdict
7+
8+
APPROVED.
9+
10+
H1–H4 are resolved. H5 is substantially improved and not worth another round.
11+
12+
## H1 — the exit criterion is now guarded, verified by counterfactual
13+
14+
Round 01's finding was that the whole suite passed while full export materialised the entire
15+
result. I re-applied that exact mutation — `con.sql(...).pl()` in place of the `COPY ... TO` —
16+
and the guard now fires:
17+
18+
```text
19+
FAILED tests/test_full_export.py::test_full_export_issues_copy_without_materialising_result[csv]
20+
FAILED tests/test_full_export.py::test_full_export_issues_copy_without_materialising_result[parquet]
21+
2 failed, 350 passed
22+
```
23+
24+
Parametrized across both formats, and it asserts the property rather than the output file —
25+
which is what makes it able to fail. The exit criterion is now defended against regression, not
26+
merely satisfied today.
27+
28+
## H2, H3, H4 — resolved
29+
30+
- **H2 cancellation** — `test_export_controller.py` now asserts the destination survives
31+
byte-identical (`destination.read_bytes() == b"original"`), **no temp file remains**
32+
(`list(tmp_path.glob(".out.csv.*")) == []`), cancelling a finished export is safe
33+
(`controller.cancel() is False`), the handle is published before work, and a failure is a
34+
terminal result rather than an exception.
35+
- **H3 duplication removed** — `services/selection.py` is now the single implementation, and I
36+
verified by identity rather than by reading imports:
37+
38+
```text
39+
old top-level selection.py removed
40+
clipboard uses shared: True
41+
preview uses shared : True
42+
```
43+
44+
Both call sites resolve to the *same function objects*, so copy and export cannot drift apart.
45+
- **H4 mutations** — all six recorded with observed failure nodes.
46+
47+
## H5 — good enough
48+
49+
Round 02 produced five commits along review-item lines rather than one lump. `bf94cc3` still
50+
combines Tasks 9 and 10 coverage. That is a marked improvement and I am not spending a round on
51+
commit shape.
52+
53+
## Final state — measured by review
54+
55+
| check | result |
56+
|---|---|
57+
| suite on **3.14** | 352 passed, 1 skipped |
58+
| suite on **3.12** | 352 passed, 1 skipped — identical |
59+
| `run-quality-gates` | pass |
60+
| **V11** crash gate 25 + 25 | **0 native crashes / 50** |
61+
| **V9** 3.14-only syntax | none |
62+
| **V2** Streamlit + `export/` diff | empty |
63+
| H1 mutation (materialise) | **now bites** — was the defect |
64+
| shared selection identity | same function objects |
65+
| `git status --short` | clean |
66+
67+
## What this phase delivered
68+
69+
Path-based export for the desktop: destination normalization, atomic writes, preview writers
70+
for CSV/XLSX/Parquet, selection export in visual order, **full export streamed through DuckDB
71+
`COPY`**, an XLSX size guard, source-change warnings, an export controller with cancellation,
72+
a native save dialog, and progress/cancel wiring with `shutdown()` in `closeEvent`.
73+
74+
The exit criteria hold: exported files reopen and match rows/columns/order; full export does not
75+
materialise the result; a failed export leaves the destination byte-identical.
76+
77+
## Note on process
78+
79+
Round 01's session log recorded **"not measured"** for the mutations and crash batches instead
80+
of inventing them. That honesty is what made this review efficient — it told me exactly where to
81+
look, and the one real defect was found in the first place I checked. It is worth repeating that
82+
this is the desired behaviour, not a shortfall.
83+
84+
## Deferred — correctly recorded
85+
86+
No human has exported from a real window; all Qt tests are offscreen. **Streaming is verified
87+
structurally — a `COPY` is issued and no materialisation call occurs — not by a memory
88+
measurement**; no multi-gigabyte export was performed. Spark export unverified. macOS and
89+
Windows save dialogs unverified. Phase 13 is Spark, Phase 14 removes Streamlit.
90+
91+
STATUS: APPROVED

0 commit comments

Comments
 (0)