|
15 | 15 |
|
16 | 16 | from ._dependencies import require_yaml |
17 | 17 | from .integrations import try_render_rich_table |
| 18 | +from .json_contracts import dumps_strict_json |
18 | 19 |
|
19 | 20 | PUBLIC_OUTPUT_FORMATS = ("text", "csv", "tsv", "yaml", "json", "ndjson") |
20 | 21 | NDJSON_SCHEMA = "base-cli.record" |
@@ -62,7 +63,10 @@ def write(self, record: StructuredRecord) -> None: |
62 | 63 | "schema": self.schema, |
63 | 64 | "record": dict(record), |
64 | 65 | } |
65 | | - self.stream.write(json.dumps(payload, separators=(",", ":"))) |
| 66 | + # Serialize the complete record before touching the sink: with strict |
| 67 | + # JSON, a nested NaN/Infinity must not leave a partial NDJSON line. |
| 68 | + encoded = json.dumps(payload, separators=(",", ":"), allow_nan=False) |
| 69 | + self.stream.write(encoded) |
66 | 70 | self.stream.write("\n") |
67 | 71 | self.stream.flush() |
68 | 72 |
|
@@ -135,21 +139,23 @@ def render_records( |
135 | 139 | resolved = resolve_output_format(requested_format, stream=target) |
136 | 140 |
|
137 | 141 | if resolved in ("csv", "tsv"): |
| 142 | + record_list = [dict(record) for record in records] |
| 143 | + _validate_delimited_records(record_list, columns) |
138 | 144 | delimiter = "," if resolved == "csv" else "\t" |
139 | 145 | writer = csv.writer(target, delimiter=delimiter, lineterminator="\n") |
140 | | - for record in records: |
141 | | - writer.writerow([_delimited_value(record.get(key)) for _header, key in columns]) |
| 146 | + for row in record_list: |
| 147 | + writer.writerow([_delimited_value(row.get(key)) for _header, key in columns]) |
142 | 148 | return resolved |
143 | 149 |
|
144 | 150 | if resolved == "ndjson": |
145 | 151 | ndjson_writer = NdjsonWriter(target) |
146 | | - for record in records: |
147 | | - ndjson_writer.write(record) |
| 152 | + for structured_record in records: |
| 153 | + ndjson_writer.write(structured_record) |
148 | 154 | return resolved |
149 | 155 |
|
150 | 156 | record_list = [dict(record) for record in records] |
151 | 157 | if resolved == "json": |
152 | | - target.write(json.dumps(record_list, separators=(",", ":"))) |
| 158 | + target.write(dumps_strict_json(record_list, separators=(",", ":"))) |
153 | 159 | target.write("\n") |
154 | 160 | return resolved |
155 | 161 |
|
@@ -195,7 +201,7 @@ def render_document( |
195 | 201 | if resolved == "text": |
196 | 202 | return resolved |
197 | 203 | if resolved == "json": |
198 | | - target.write(json.dumps(dict(document), indent=2)) |
| 204 | + target.write(dumps_strict_json(dict(document), indent=2)) |
199 | 205 | target.write("\n") |
200 | 206 | return resolved |
201 | 207 | if resolved == "yaml": |
@@ -249,10 +255,23 @@ def _cell_value(value: Any) -> str: |
249 | 255 | if isinstance(value, bool): |
250 | 256 | return "true" if value else "false" |
251 | 257 | if isinstance(value, (Mapping, list, tuple)): |
252 | | - return json.dumps(value, separators=(",", ":")) |
| 258 | + return dumps_strict_json(value, separators=(",", ":")) |
253 | 259 | return str(value) |
254 | 260 |
|
255 | 261 |
|
| 262 | +def _validate_delimited_records( |
| 263 | + records: Sequence[Mapping[str, Any]], |
| 264 | + columns: Sequence[tuple[str, str]], |
| 265 | +) -> None: |
| 266 | + """Validate nested cell values before a delimited stream is touched.""" |
| 267 | + |
| 268 | + for record in records: |
| 269 | + for _header, key in columns: |
| 270 | + value = record.get(key) |
| 271 | + if isinstance(value, (Mapping, list, tuple)): |
| 272 | + dumps_strict_json(value, separators=(",", ":")) |
| 273 | + |
| 274 | + |
256 | 275 | def _delimited_value(value: Any) -> str: |
257 | 276 | """Return a safe scalar for redirected CSV/TSV output. |
258 | 277 |
|
|
0 commit comments