From 302be529219b2529621eae935fd49d1bc8ad758a Mon Sep 17 00:00:00 2001 From: stroblme Date: Sun, 30 Aug 2026 17:36:10 +0200 Subject: [PATCH] Let a jsonl export keep a record a value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--format jsonl` existed, but `_cell` ran json.dumps at row-build time, before a format was chosen — so a nested value was a string by then and jsonl only re-escaped it, leaving a consumer against csv's 128KB field limit either way. Stringifying moved to the csv writer, so csv is byte-identical and jsonl carries json. The runs TUI followed, or a record would draw as a Python repr. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KYM38KSb4V4v2T71eifnZv --- backend/fluksio/api/routes/runs.py | 26 ++++++++++++++++++-------- backend/fluksio/sdk/cli.py | 13 ++++++++++++- backend/fluksio/tui/compare.py | 5 +++++ backend/tests/api/routes/test_runs.py | 10 ++++++++++ 4 files changed, 45 insertions(+), 9 deletions(-) diff --git a/backend/fluksio/api/routes/runs.py b/backend/fluksio/api/routes/runs.py index e71f2d1..54af8ec 100644 --- a/backend/fluksio/api/routes/runs.py +++ b/backend/fluksio/api/routes/runs.py @@ -406,12 +406,10 @@ def _selected( def _cell(value: Any) -> Any: - """A value as a table holds it: a scalar, or JSON when it is not one.""" - if hasattr(value, "isoformat"): - return value.isoformat() - if value is None or isinstance(value, (str, int, float, bool)): - return value - return json.dumps(value) + """A value as a table holds it: a scalar, a date as its ISO string, or a + record kept as one — csv stringifies it at write time; jsonl wants it a + value, not a string full of it.""" + return value.isoformat() if hasattr(value, "isoformat") else value def _leaves(value: Any, prefix: str = "") -> Iterator[tuple[str, Any]]: @@ -464,13 +462,25 @@ def _drain(buffer: io.StringIO) -> str: def _csv(columns: list[str], chunks: Iterator[list[dict[str, Any]]]) -> Iterator[str]: - """Header first, then a chunk at a time through one reused buffer.""" + """Header first, then a chunk at a time through one reused buffer. + + A cell that is still a record here — `_cell` leaves one alone for jsonl's + sake — becomes the JSON string a csv cell can hold. + """ buffer = io.StringIO() writer = csv.DictWriter(buffer, fieldnames=columns) writer.writeheader() yield _drain(buffer) for chunk in chunks: - writer.writerows(chunk) + writer.writerows( + { + key: value + if value is None or isinstance(value, (str, int, float, bool)) + else json.dumps(value) + for key, value in row.items() + } + for row in chunk + ) yield _drain(buffer) diff --git a/backend/fluksio/sdk/cli.py b/backend/fluksio/sdk/cli.py index b4dad6f..d4369d2 100644 --- a/backend/fluksio/sdk/cli.py +++ b/backend/fluksio/sdk/cli.py @@ -1128,9 +1128,20 @@ def _write_rows(rows: list[dict[str, Any]], fmt: str, out: str, hint: str = "") if fmt == "jsonl": handle.writelines(json.dumps(row) + "\n" for row in rows) else: + # The wire is jsonl either way (see `Client._export`), so a record + # a jsonl row keeps as a value is still one here — stringify it + # for the csv cell it has to sit in. writer = csv.DictWriter(handle, fieldnames=list(rows[0])) writer.writeheader() - writer.writerows(rows) + writer.writerows( + { + key: value + if value is None or isinstance(value, (str, int, float, bool)) + else json.dumps(value) + for key, value in row.items() + } + for row in rows + ) return 0 diff --git a/backend/fluksio/tui/compare.py b/backend/fluksio/tui/compare.py index 7e6875e..8a3a689 100644 --- a/backend/fluksio/tui/compare.py +++ b/backend/fluksio/tui/compare.py @@ -9,6 +9,7 @@ So nothing here computes what a route already knows. from __future__ import annotations +import json from typing import Any from rich.text import Text @@ -45,6 +46,10 @@ def _cell(value: Any) -> str: """ if isinstance(value, float): return f"{value:.4g}" + if isinstance(value, (dict, list)): + # An export keeps a record a value now, and python's repr of one is + # not what anybody reading a run wrote down. + return json.dumps(value) return "" if value is None else str(value) diff --git a/backend/tests/api/routes/test_runs.py b/backend/tests/api/routes/test_runs.py index 423d9ee..2fba021 100644 --- a/backend/tests/api/routes/test_runs.py +++ b/backend/tests/api/routes/test_runs.py @@ -4,6 +4,7 @@ The pipeline half — what a hit restores and what a key is made of — is in `tests/flow/test_runs.py`, which runs without one. """ +import csv import json from datetime import UTC, datetime, timedelta @@ -954,6 +955,15 @@ def test_an_exported_run_row_carries_every_recorded_input( "metric.test_metrics.known.perfect" ) + # `final_metrics` alone, short of `.train_loss`, is a record rather than a + # leaf — the shape a large nested blob is. jsonl keeps it a json value; + # csv still has nowhere to put it but a quoted string, unchanged. + nested = _lines(export(format="jsonl", metrics="final_metrics")) + assert nested[0]["metric.final_metrics"] == {"train_loss": 0.5} + + rows = list(csv.reader(export(metrics="final_metrics").text.splitlines())) + assert rows[1][-1] == '{"train_loss": 0.5}' + @pytest.fixture def paged():