Let a jsonl export keep a record a value
Docs / docs (push) Successful in 22s
Playwright Tests / test-playwright (1, 2) (push) Failing after 2m30s
Playwright Tests / test-playwright (2, 2) (push) Failing after 12s
pre-commit / pre-commit (push) Failing after 2m2s
Test Backend / test-backend (push) Failing after 2m34s
Compose Smoke Test / test-compose (push) Failing after 12s
Playwright Tests / merge-reports (push) Failing after 2m49s
Docs / docs (push) Successful in 22s
Playwright Tests / test-playwright (1, 2) (push) Failing after 2m30s
Playwright Tests / test-playwright (2, 2) (push) Failing after 12s
pre-commit / pre-commit (push) Failing after 2m2s
Test Backend / test-backend (push) Failing after 2m34s
Compose Smoke Test / test-compose (push) Failing after 12s
Playwright Tests / merge-reports (push) Failing after 2m49s
`--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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KYM38KSb4V4v2T71eifnZv
This commit is contained in:
@@ -406,12 +406,10 @@ def _selected(
|
|||||||
|
|
||||||
|
|
||||||
def _cell(value: Any) -> Any:
|
def _cell(value: Any) -> Any:
|
||||||
"""A value as a table holds it: a scalar, or JSON when it is not one."""
|
"""A value as a table holds it: a scalar, a date as its ISO string, or a
|
||||||
if hasattr(value, "isoformat"):
|
record kept as one — csv stringifies it at write time; jsonl wants it a
|
||||||
return value.isoformat()
|
value, not a string full of it."""
|
||||||
if value is None or isinstance(value, (str, int, float, bool)):
|
return value.isoformat() if hasattr(value, "isoformat") else value
|
||||||
return value
|
|
||||||
return json.dumps(value)
|
|
||||||
|
|
||||||
|
|
||||||
def _leaves(value: Any, prefix: str = "") -> Iterator[tuple[str, Any]]:
|
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]:
|
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()
|
buffer = io.StringIO()
|
||||||
writer = csv.DictWriter(buffer, fieldnames=columns)
|
writer = csv.DictWriter(buffer, fieldnames=columns)
|
||||||
writer.writeheader()
|
writer.writeheader()
|
||||||
yield _drain(buffer)
|
yield _drain(buffer)
|
||||||
for chunk in chunks:
|
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)
|
yield _drain(buffer)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1128,9 +1128,20 @@ def _write_rows(rows: list[dict[str, Any]], fmt: str, out: str, hint: str = "")
|
|||||||
if fmt == "jsonl":
|
if fmt == "jsonl":
|
||||||
handle.writelines(json.dumps(row) + "\n" for row in rows)
|
handle.writelines(json.dumps(row) + "\n" for row in rows)
|
||||||
else:
|
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 = csv.DictWriter(handle, fieldnames=list(rows[0]))
|
||||||
writer.writeheader()
|
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
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ So nothing here computes what a route already knows.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
@@ -45,6 +46,10 @@ def _cell(value: Any) -> str:
|
|||||||
"""
|
"""
|
||||||
if isinstance(value, float):
|
if isinstance(value, float):
|
||||||
return f"{value:.4g}"
|
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)
|
return "" if value is None else str(value)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
`tests/flow/test_runs.py`, which runs without one.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import csv
|
||||||
import json
|
import json
|
||||||
from datetime import UTC, datetime, timedelta
|
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"
|
"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
|
@pytest.fixture
|
||||||
def paged():
|
def paged():
|
||||||
|
|||||||
Reference in New Issue
Block a user