Skip to content

Commit bf8df80

Browse files
committed
fix(reporting): append UUID only on collision
1 parent 6bc2bc2 commit bf8df80

3 files changed

Lines changed: 38 additions & 14 deletions

File tree

‎docs/usage/results-and-reporting.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,9 @@ from rampart.reporting import JsonFileReportSink
8787
sink = JsonFileReportSink(output_dir=Path(".report"))
8888
```
8989

90-
Output: `.report/run_report_2026-04-25T14-30-00-123_a3f18c92654d4b75ad15687d383d951b.json`
90+
Output: `.report/run_report_2026-04-25T14-30-00-123.json`
9191

92-
The filename contains a UTC timestamp (millisecond precision) and a random UUID. Reports created in the same millisecond receive different filenames. An exact filename collision raises `FileExistsError` instead of overwriting an existing report. Reports written within the same millisecond have no defined filename order relative to each other.
92+
The filename contains a UTC timestamp with millisecond precision. If another report already has the same timestamp, a random UUID is appended to the new filename. Files are created atomically and existing reports are never overwritten. Reports written within the same millisecond have no defined filename order relative to each other.
9393

9494
### Custom Sinks
9595

‎rampart/reporting/json_file.py‎

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,10 @@ def rampart_sinks():
4646
class JsonFileReportSink:
4747
"""Writes the test run report to a JSON file.
4848
49-
Each run produces a file named ``run_report_<timestamp>_<uuid>.json``.
50-
The UTC timestamp includes milliseconds; the UUID distinguishes runs
51-
created in the same millisecond. Existing files are never overwritten.
49+
Each run normally produces a file named ``run_report_<timestamp>.json``.
50+
The UTC timestamp includes milliseconds. If that filename already exists,
51+
a UUID suffix distinguishes the colliding run. Existing files are never
52+
overwritten.
5253
5354
Args:
5455
output_dir (Path): Directory to write report files into.
@@ -66,16 +67,29 @@ async def emit_async(self, *, report: TestRunReport) -> None:
6667
report (TestRunReport): The aggregated test run results.
6768
6869
Raises:
69-
FileExistsError: If the generated filename already exists, or
70+
FileExistsError: If the random fallback filename also exists, or
7071
``output_dir`` exists and is not a directory.
7172
"""
7273
self._output_dir.mkdir(parents=True, exist_ok=True)
7374

7475
timestamp = datetime.now(UTC).strftime("%Y-%m-%dT%H-%M-%S-%f")[:-3]
75-
filepath = self._output_dir / f"run_report_{timestamp}_{uuid4().hex}.json"
7676
data = self._serialize_report(report)
7777
content = json.dumps(data, indent=2, default=str)
78-
with filepath.open("x", encoding="utf-8") as report_file:
78+
79+
filepath = self._output_dir / f"run_report_{timestamp}.json"
80+
try:
81+
report_file = filepath.open("x", encoding="utf-8")
82+
except FileExistsError:
83+
filepath = self._output_dir / f"run_report_{timestamp}_{uuid4().hex}.json"
84+
85+
# Leave the exception handler before opening the fallback so a
86+
# second collision reports only the path that actually collided.
87+
report_file = None
88+
89+
if report_file is None:
90+
report_file = filepath.open("x", encoding="utf-8")
91+
92+
with report_file:
7993
report_file.write(content)
8094

8195
def _serialize_report(self, report: TestRunReport) -> dict[str, Any]:

‎tests/unit/reporting/test_json_file.py‎

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -388,14 +388,21 @@ async def test_same_timestamp_preserves_every_report_async(
388388
json.loads(path.read_text(encoding="utf-8"))["metadata"]["run"]
389389
for path in files
390390
} == {0, 1, 2}
391-
for path in files:
391+
392+
concise = tmp_path / "run_report_2026-08-27T12-00-00-123.json"
393+
assert json.loads(concise.read_text(encoding="utf-8"))["metadata"] == {
394+
"run": 0,
395+
}
396+
colliding_files = [path for path in files if path != concise]
397+
assert len(colliding_files) == 2
398+
for path in colliding_files:
392399
assert path.name.startswith("run_report_2026-08-27T12-00-00-123_")
393400
identifier = path.stem.rsplit("_", 1)[1]
394401
assert len(identifier) == 32
395402
assert UUID(hex=identifier).version == 4
396403

397404
async def test_existing_report_is_not_replaced_async(self, tmp_path: Path) -> None:
398-
original = tmp_path / "run_report_2026-08-27T12-00-00.json"
405+
original = tmp_path / "run_report_2026-08-27T12-00-00-000.json"
399406
original.write_text("keep me", encoding="utf-8")
400407
sink = JsonFileReportSink(output_dir=tmp_path)
401408
fixed = datetime(2026, 8, 27, 12, 0, 0, tzinfo=UTC)
@@ -416,10 +423,12 @@ async def test_uuid_collision_does_not_overwrite_existing_report_async(
416423
tmp_path: Path,
417424
) -> None:
418425
identifier = UUID("a3f18c92-654d-4b75-ad15-687d383d951b")
419-
original = (
426+
timestamp_file = tmp_path / "run_report_2026-08-27T12-00-00-000.json"
427+
timestamp_file.write_text("keep timestamp", encoding="utf-8")
428+
uuid_file = (
420429
tmp_path / f"run_report_2026-08-27T12-00-00-000_{identifier.hex}.json"
421430
)
422-
original.write_text("keep me", encoding="utf-8")
431+
uuid_file.write_text("keep uuid", encoding="utf-8")
423432
sink = JsonFileReportSink(output_dir=tmp_path)
424433
fixed = datetime(2026, 8, 27, 12, 0, 0, tzinfo=UTC)
425434

@@ -431,8 +440,9 @@ async def test_uuid_collision_does_not_overwrite_existing_report_async(
431440
with pytest.raises(FileExistsError, match=identifier.hex):
432441
await sink.emit_async(report=TestRunReport())
433442

434-
assert original.read_text(encoding="utf-8") == "keep me"
435-
assert list(tmp_path.glob("run_report_*.json")) == [original]
443+
assert timestamp_file.read_text(encoding="utf-8") == "keep timestamp"
444+
assert uuid_file.read_text(encoding="utf-8") == "keep uuid"
445+
assert set(tmp_path.glob("run_report_*.json")) == {timestamp_file, uuid_file}
436446

437447
async def test_serialization_failure_does_not_create_a_file_async(
438448
self,

0 commit comments

Comments
 (0)