Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ adheres to [Semantic Versioning](https://semver.org/).
## [Unreleased]

### Added
- `CleanReport.to_json()` and `CleanReport.write_json()` for first-class audit
report serialization without manual `json.dumps(...)` calls.
- Added a runnable PyJanitor interoperability example that demonstrates both
tool orderings while keeping PyJanitor optional.
- A dependency-optional Great Expectations recipe demonstrating the
Expand Down
2 changes: 2 additions & 0 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ The report is also machine-readable:
```python
report.to_frame() # one row per decision, as a DataFrame
report.to_dict() # JSON-friendly for logging / dashboards
report.to_json(indent=2) # JSON string
report.write_json("report.json", indent=2) # UTF-8 JSON file
```

## Preview before cleaning
Expand Down
5 changes: 1 addition & 4 deletions examples/08_csv_automation.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
python examples/08_csv_automation.py
"""

import json
import tempfile
from pathlib import Path

Expand Down Expand Up @@ -43,9 +42,7 @@ def main() -> None:
for path in sorted(inbox.glob("*.csv")):
cleaned = cleaner.clean(pd.read_csv(path))
cleaned.to_csv(outbox / path.name, index=False)
(logs / f"{path.stem}.json").write_text(
json.dumps(cleaner.report_.to_dict(), indent=2, default=str)
)
cleaner.report_.write_json(logs / f"{path.stem}.json", indent=2)
print(f"{path.name}: {cleaner.report_.summary().splitlines()[0]}")

print(f"\nCleaned {len(list(outbox.glob('*.csv')))} files; "
Expand Down
17 changes: 17 additions & 0 deletions src/freshdata/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@
from __future__ import annotations

import contextlib
import json
from collections.abc import Iterator
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

import pandas as pd
Expand Down Expand Up @@ -379,6 +381,21 @@ def to_dict(self) -> dict[str, Any]:
payload["profile_replay"] = dict(self.profile_replay)
return payload

def to_json(self, **kwargs: Any) -> str:
"""Serialize the report's stable audit payload as JSON.

Keyword arguments are forwarded to :func:`json.dumps`, so callers can
choose options such as ``indent=2`` or ``sort_keys=True``. Nested audit
values outside JSON's native types are stringified by default; pass a
custom ``default=`` callback to override that behavior.
"""
kwargs.setdefault("default", str)
return json.dumps(self.to_dict(), **kwargs)
Comment thread
WilliamK112 marked this conversation as resolved.

def write_json(self, path: str | Path, **kwargs: Any) -> None:
"""Write the report's JSON audit payload to *path* as UTF-8 text."""
Path(path).write_text(self.to_json(**kwargs) + "\n", encoding="utf-8")

def revert(
self, df: pd.DataFrame, action_ids: list[str] | None = None
) -> pd.DataFrame:
Expand Down
40 changes: 40 additions & 0 deletions tests/test_report.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import json
from datetime import datetime, timezone
from pathlib import Path

import pandas as pd

Expand Down Expand Up @@ -31,6 +33,44 @@ def test_to_dict_is_json_serializable(messy):
assert len(payload_dict["actions"]) == len(report)


def test_to_json_serializes_real_report(messy):
_, report = fd.clean(messy, return_report=True)

payload = json.loads(report.to_json(indent=2, sort_keys=True))

assert payload == report.to_dict()
assert len(payload["actions"]) == len(report)


def test_write_json_round_trip(messy, tmp_path: Path):
_, report = fd.clean(messy, return_report=True)
path = tmp_path / "audit" / "report.json"
path.parent.mkdir()

result = report.write_json(path, indent=2)

assert result is None
assert json.loads(path.read_text(encoding="utf-8")) == report.to_dict()
assert path.read_bytes().endswith(b"\n")


def test_json_exports_serialize_nested_audit_values(messy, tmp_path: Path):
extracted_at = datetime(2026, 8, 20, 9, 30, tzinfo=timezone.utc)
_, report = fd.clean(
messy,
source_provenance={"AGE": {"extracted_at": extracted_at}},
return_report=True,
)
path = tmp_path / "report.json"

payload = json.loads(report.to_json())
report.write_json(path)

assert report.to_dict()["source_provenance"]["AGE"]["extracted_at"] is extracted_at
assert payload["source_provenance"]["AGE"]["extracted_at"] == str(extracted_at)
assert json.loads(path.read_text(encoding="utf-8")) == payload


def test_to_frame(messy):
_, report = fd.clean(messy, return_report=True)
frame = report.to_frame()
Expand Down
Loading