Skip to content

feat(cli): let every command write its result to a file - #147

Merged
L4XB merged 1 commit into
mainfrom
feat/cli-output
Sep 15, 2026
Merged

L4XB merged 1 commit into
mainfrom
feat/cli-output

Conversation

@L4XB

@L4XB L4XB commented Sep 15, 2026

Copy link
Copy Markdown
Member

Summary

Exactly one of fifteen subcommands took --output. Every command takes it now,
and they all behave the same way, because they all go through one writer.

Which commands were left out, and why

None. The issue asks to exclude commands whose output is a progress report
rather than a result; applying that test to this CLI leaves an empty set. Each
of the fifteen prints one artifact and then exits:

Command What it prints
query the compiled query — the thing that goes into a methods appendix
corpus-build the checksummed manifest of the snapshot it just built
corpus-search matching records as JSONL
openalex-search fetched records as JSONL
rank ranked records with their decomposed signals
coverage the estimate and its stated assumptions
prisma the rendered flow (already had it)
expansion-validate the validation result
data-profile the deterministic profile
data-analyze × 5 recipes the analysis result and its method limits

corpus-build is the one that could be argued either way: it performs work and
then reports on it. But the report is the manifest — source, work count, and
the SHA-256 of the snapshot — which is precisely what a reproducible build has
to keep. Progress, where it exists at all, goes to stderr and never enters the
file.

Behavior and compatibility

A file gets exactly the bytes stdout would have got

_command_prisma appended a trailing newline for --format text and not for
--format svg, so the same command wrote different bytes to a file than to a
pipe. That asymmetry was the only precedent to copy, and copying it fifteen
times would have made --output mean something slightly different per command.

One rule replaces it: build the complete payload — trailing newline included,
exactly as print would have produced it — then either write it or print it.
diff <(sixsentences … ) file is empty for every command.

One compatibility note: sixsentences prisma counts.json --format svg --output flow.svg now writes a file ending in \n. One byte, on a format
where trailing whitespace outside the root element is insignificant, in
exchange for one rule instead of a per-command exception. Called out here
because it is a change to an existing command rather than a new option.

A failed command writes nothing

The payload is assembled in full before the path is opened, so a command that
raises partway leaves no artifact — not a truncated one, and not a stale one it
started to overwrite. This is the failure mode the issue names about >, which
creates the file before the command has produced anything.
test_a_failing_command_writes_no_file pins it.

data-analyze takes it in both positions

sixsentences data-analyze obs.csv --output result.json descriptive --column score
sixsentences data-analyze obs.csv descriptive --column score --output result.json

Both work and produce the same file. This needed care: _SubParsersAction
parses the recipe into a fresh namespace and then copies every key back over
the parent's, so a recipe-level --output with the usual default=None would
silently erase a value the parent had already read. argparse.SUPPRESS as the
recipe default leaves the key absent unless it was actually given.
test_output_is_accepted_before_and_after_an_analysis_recipe pins it.

Not changed

  • stdout when --output is absent: byte-for-byte identical. The pre-existing
    tests in tests/test_cli.py were not touched and still pass.
  • Exit codes, error messages, and the stderr/stdout split.
  • Public API, schemas, migrations. Reverting the commit needs no state change.

Validation

$ uv run ruff check src tests .github/scripts          # All checks passed!
$ uv run ruff format --check src tests .github/scripts # 64 files already formatted
$ uv run mypy src/sixsentences                         # no issues found in 40 source files
$ uv run pytest -q                                     # 376 passed  (355 before)
$ uv build                                             # sdist + wheel

The 21 new tests are:

  • 16 parametrized cases asserting the written file equals the captured stdout
    byte for byte — one per deterministic command, including both prisma
    formats and all five analysis recipes;
  • one guard that the parametrized list cannot drift from the fixture;
  • openalex-search against a stub client, so the networked command is covered
    without egress;
  • corpus-build, where byte parity cannot be asserted because
    CorpusManifest.created_at carries a build time — the test reads the written
    manifest instead;
  • the both-positions case and the failed-command case described above.

Engine only. The API, web client, browser extension, macOS Companion and the
self-hosting definition do not call this CLI.

  • Engine checks pass.
  • API, migration, worker, and web-contract checks pass, or they are unaffected. — unaffected
  • Web type-check, tests, and production build pass, or the web app is unaffected. — unaffected
  • Browser-extension contracts and a deployment-bound build pass, or the extension is unaffected. — unaffected
  • macOS Companion boundary check, locked resolution, tests, and release build pass on the pinned Xcode toolchains—or the Companion is unaffected. — unaffected
  • Self-hosting tests and container builds pass, or deployment is unaffected. — unaffected
  • User-facing behavior has a focused test or the omission is explained.

Review boundaries

  • Security and privacy effects were reviewed. --output writes to a path
    the caller supplied on their own command line, with the caller's own
    permissions, using Path.write_text. No path is derived from input data,
    no directory is created, and an existing file is replaced rather than
    appended to. Authentication, tenancy, uploads, retention and deletion are
    not involved.
  • New network calls and processors are operator-configurable, fail closed, and document data egress, cost, retention, and failure behavior—or none were added. — none; openalex-search reaches the network exactly as before
  • Dependencies and bundled assets are justified, locked, and redistribution-compatible—or none were added. — none
  • Native-client changes include explicit origin, local-retention/deletion, permission, signing, update, and binary-distribution implications. — none
  • Research-method assumptions, limitations, and provenance remain visible—or
    no research-facing behavior changed. Each result keeps its stated limits;
    writing it to a file makes the record easier to keep beside the data it
    describes, not easier to strip.
  • Accessibility and keyboard behavior were reviewed for UI changes—or no UI changed. — no UI
  • Browser permissions, capture bounds, pairing callbacks, extension storage, and generated host access were reviewed—or the extension is unaffected. — unaffected

Source-release hygiene

  • No secret, private key, production configuration, customer/participant data, user upload, database dump, log, private prompt, or non-redistributable research content is included.
  • The change belongs in the community stack; payment, subscription, commercial-plan, hosted-administration, and marketing-site code remains separate.
  • Public behavior and limitations are documented.
  • CHANGELOG.md is updated for user-visible changes.
  • Every commit carries my own matching DCO Signed-off-by trailer.
  • I have read CLA.md and posted its exact acceptance sentence as a standalone pull-request comment.
  • I have read and will follow the Code of Conduct.

Visual evidence

No UI change.

Noticed while here, not fixed here

corpus-build cannot produce a byte-identical result twice:
CorpusManifest.created_at defaults to utcnow(). The snapshot itself is
deterministic and checksummed; only the manifest wrapper carries the time. It is
worth deciding whether that field belongs in a document whose point is to be
comparable — but not in this pull request.

Closes #124

@L4XB

L4XB commented Sep 15, 2026

Copy link
Copy Markdown
Member Author

I have read and agree to the SixSentences CLA v1.0.

@L4XB

L4XB commented Sep 15, 2026

Copy link
Copy Markdown
Member Author

Self-review

Recorded under the founding-maintainer exception in GOVERNANCE.md. Three
files. Rebased onto main after #144, which touched all three; the conflicts
were both additive and resolved by keeping both sides.

src/sixsentences/cli.py

  • _print_json_json_text and _emit_records_jsonl_text. The change
    is the same in every case: produce the string print would have produced,
    including its trailing newline, and hand it to one writer. Every call site was
    read; none does anything else with the value.
  • cast(str, …) on model_dump_json. hasattr duck-typing was kept rather
    than switched to isinstance(value, BaseModel), because the second would be a
    behaviour change hidden inside a refactor. mypy needs the cast because value
    is object; the previous code escaped it only because print accepts
    anything.
  • _jsonl_text(records: Iterable[BaseModel]) now serves corpus-search,
    openalex-search and rank. rank yields RankedWork, the other two
    WorkRecord; both are pydantic models, so the widened parameter type is
    accurate rather than convenient, and it removed a hand-rolled join that
    duplicated the helper.
  • _emit reads getattr(args, "output", None). Deliberate: with
    argparse.SUPPRESS on the recipe parsers the attribute can legitimately be
    absent, and a getattr default is the honest way to say so.
  • Path.write_text replaces rather than appends, and is reached only after the
    payload exists. A command that raises earlier leaves the path untouched —
    asserted by test_a_failing_command_writes_no_file.
  • _add_output(parser, result, *, nested=False). One definition, so
    "identically across every command" is structural rather than a promise. The
    nested flag is the whole SUPPRESS subtlety, named and commented at the one
    place it exists.
  • Parser wiring: fifteen call sites, read one by one against the handler each
    one dispatches to. prisma's hand-written --output was replaced by the
    shared helper, so it gained metavar="PATH" and a help string it did not
    have.

tests/test_cli.py — 21 tests added, none modified. The parametrized cases
are named rather than indexed, so a failure says prisma-svg rather than 8,
and test_every_deterministic_command_is_parametrized fails if the name list
and the fixture drift apart. Two commands are covered separately for stated
reasons: openalex-search needs a stub client to stay offline, and
corpus-build cannot be compared byte for byte because its manifest carries
created_at.

CHANGELOG.md — one Added entry and one Changed entry. The Changed
one exists because the SVG file gaining a trailing newline is a change to an
existing command, not a new option, and someone diffing generated artifacts
across versions deserves to find it written down.

The judgement call

prisma --format svg --output writes one byte more than before. I chose one
rule — a file receives exactly what stdout would have received — over preserving
a per-command exception, because the alternative was fifteen commands each
needing to be checked individually for what they do to the last byte. Trailing
whitespace outside the root element is insignificant in XML, and an SVG without
a final newline is the unusual case rather than the careful one. It is called
out in the description and the changelog rather than left to be discovered.

Checks

Every required context is green after the rebase. Engine only; the API, web
client, extension and Companion do not call this CLI.

Merging.

Exactly one of fifteen subcommands took `--output`. The rest printed to stdout
and nothing else, so keeping a compiled query for a methods appendix or a
coverage estimate for a reproducibility record meant shell redirection — which
captures whatever was printed and gives no signal when the command failed
partway through.

Every command now takes it, because every command in this CLI prints a result
rather than a progress report. `corpus-build` is the closest call: it does work
and then reports on it, but what it reports is the checksummed manifest, which
is exactly the artifact a reproducible build needs to keep.

Two decisions worth naming.

A file receives exactly the bytes stdout would have received. `_command_prisma`
used to append a newline for text and not for SVG, so the same command wrote
different bytes to a file than to a pipe. One rule replaces that: build the
complete payload, then either write it or print it. The only visible
consequence is that a written SVG now ends with a newline.

The path is touched only once the payload is complete, so a command that raises
partway leaves no half-written artifact — the failure mode redirection cannot
avoid.

`data-analyze` accepts the option before the recipe and after it. A nested
parser normally clobbers a value the parent already read, because argparse
copies the whole sub-namespace back; `argparse.SUPPRESS` as the recipe default
keeps the parent's value when the recipe does not set one.

Closes #124

Signed-off-by: L4XB <L4XB@users.noreply.github.com>
@L4XB

L4XB commented Sep 15, 2026

Copy link
Copy Markdown
Member Author

Addendum to the self-review

Rebased twice since that comment was written — once onto #148, once onto #151
and re-verified after each. The resolution was unchanged both times: neither
touched src/sixsentences/cli.py.

The Application API quality and tests failure in between was not this change.
test_cancel_closes_upstream_before_terminal_callback_and_retains_pending_cost
timed out on a runner doing the suite in 605 s against the ~300 s it takes
locally; the test gives the whole shielded cleanup path one second. Filed as
#152 with the run and job ids, and it passed on a re-run of the same commit and
again on the rebase. This pull request touches nothing the relay imports.

All 23 contexts green. Merging.

@L4XB
L4XB merged commit 6fa79cc into main Sep 15, 2026
23 checks passed
@L4XB
L4XB deleted the feat/cli-output branch September 15, 2026 12:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

cli: only one of fifteen commands can write its result to a file

1 participant