Skip to content

Commit feed3f0

Browse files
Merge pull request #44 from Coding-Dev-Tools/improve/datamorph-20260630
test: add rows_read accounting and silent-failure regression tests
2 parents ca69f3b + 1d1e95e commit feed3f0

2 files changed

Lines changed: 150 additions & 2 deletions

File tree

src/datamorph/converters.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -521,12 +521,34 @@ def convert(
521521
except StopIteration:
522522
pass
523523

524-
# Convert
524+
# Convert. Wrap the source stream in a counter so ``rows_read`` reflects the
525+
# number of rows actually pulled from the reader -- accurate even when the
526+
# writer raises partway through (the count is committed in ``finally``).
527+
read_count = 0
528+
529+
def _counting(stream: RowStream) -> RowStream:
530+
nonlocal read_count
531+
for row in stream:
532+
read_count += 1
533+
yield row
534+
525535
try:
526536
row_stream = reader.read_stream(input_path)
527-
result.rows_written = writer.write_stream(row_stream, output_path)
537+
result.rows_written = writer.write_stream(_counting(row_stream), output_path)
528538
except Exception as e:
529539
result.errors.append(f"Conversion failed: {e}")
540+
finally:
541+
result.rows_read = read_count
542+
543+
# Silent-failure guard: rows were read but none written, yet the writer did
544+
# not raise. That is a conversion reporting green while producing nothing --
545+
# surface it as an error instead of a misleading success. Zero-in/zero-out
546+
# is legitimate and deliberately excluded.
547+
if not result.errors and result.rows_read > 0 and result.rows_written == 0:
548+
result.errors.append(
549+
f"silent failure: read {result.rows_read} row(s) from {input_format} "
550+
f"but wrote 0 to {output_format}"
551+
)
530552

531553
return result
532554

tests/test_rows_read_accounting.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
"""Tests for ConversionResult.rows_read accounting and silent-failure detection.
2+
3+
Regression coverage for the bug where ``convert()`` declared ``rows_read`` on
4+
``ConversionResult`` but never populated it (always 0), and where a conversion
5+
that read rows yet wrote none was reported as a green success.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import json
11+
12+
import pytest
13+
14+
from datamorph import converters
15+
from datamorph.converters import (
16+
FormatWriter,
17+
RowStream,
18+
convert,
19+
register_format,
20+
)
21+
22+
23+
def _write_csv(path, rows: list[dict]) -> None:
24+
import csv
25+
26+
with open(path, "w", newline="", encoding="utf-8") as f:
27+
writer = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
28+
writer.writeheader()
29+
writer.writerows(rows)
30+
31+
32+
@pytest.fixture
33+
def sample_csv(tmp_path):
34+
path = tmp_path / "in.csv"
35+
_write_csv(
36+
path,
37+
[
38+
{"id": "1", "name": "alice"},
39+
{"id": "2", "name": "bob"},
40+
{"id": "3", "name": "carol"},
41+
],
42+
)
43+
return path
44+
45+
46+
def test_rows_read_populated_csv_to_json(sample_csv, tmp_path):
47+
out = tmp_path / "out.json"
48+
result = convert(str(sample_csv), str(out))
49+
50+
assert not result.errors
51+
assert result.rows_read == 3
52+
assert result.rows_written == 3
53+
# Output actually contains the rows.
54+
assert len(json.loads(out.read_text())) == 3
55+
56+
57+
def test_rows_read_equals_written_across_formats(sample_csv, tmp_path):
58+
for ext in ("json", "jsonl", "yaml"):
59+
out = tmp_path / f"out.{ext}"
60+
result = convert(str(sample_csv), str(out))
61+
assert not result.errors, result.errors
62+
assert result.rows_read == 3, ext
63+
assert result.rows_read == result.rows_written, ext
64+
65+
66+
def test_empty_input_reports_zero_and_no_silent_failure(tmp_path):
67+
# Header only, no data rows.
68+
empty = tmp_path / "empty.csv"
69+
empty.write_text("id,name\n", encoding="utf-8")
70+
out = tmp_path / "out.json"
71+
72+
result = convert(str(empty), str(out))
73+
74+
assert result.rows_read == 0
75+
assert result.rows_written == 0
76+
# Zero-in/zero-out is legitimate and must NOT be flagged as a silent failure.
77+
assert not result.errors
78+
79+
80+
def test_silent_failure_detected_when_rows_read_but_none_written(sample_csv, tmp_path):
81+
"""A writer that consumes rows but emits none must be reported as an error,
82+
not a green 'Converted 0 rows' success."""
83+
84+
class _NullSinkWriter(FormatWriter):
85+
def write_stream(self, rows: RowStream, path) -> int:
86+
# Consume the stream (simulating real read work) but write nothing.
87+
for _ in rows:
88+
pass
89+
open(path, "w").close()
90+
return 0
91+
92+
register_format("nullsink", writer=_NullSinkWriter)
93+
try:
94+
out = tmp_path / "out.nullsink"
95+
result = convert(str(sample_csv), str(out), output_format="nullsink")
96+
97+
assert result.rows_read == 3
98+
assert result.rows_written == 0
99+
assert result.errors
100+
assert "silent failure" in result.errors[0]
101+
finally:
102+
converters._WRITERS.pop("nullsink", None)
103+
104+
105+
def test_rows_read_counted_on_partial_read_before_error(sample_csv, tmp_path):
106+
"""If the writer raises partway through, rows_read still reflects what was
107+
pulled from the source (set in a finally block)."""
108+
109+
class _ExplodingWriter(FormatWriter):
110+
def write_stream(self, rows: RowStream, path) -> int:
111+
count = 0
112+
for _ in rows:
113+
count += 1
114+
if count == 2:
115+
raise RuntimeError("boom")
116+
return count
117+
118+
register_format("boom", writer=_ExplodingWriter)
119+
try:
120+
out = tmp_path / "out.boom"
121+
result = convert(str(sample_csv), str(out), output_format="boom")
122+
123+
assert result.rows_read == 2 # counted up to the point of failure
124+
assert any("Conversion failed" in e for e in result.errors)
125+
finally:
126+
converters._WRITERS.pop("boom", None)

0 commit comments

Comments
 (0)