From c8f25aa19752d96205a0ae8a54f6d45c177f6512 Mon Sep 17 00:00:00 2001 From: Johnny Wilson Dougherty Date: Sun, 12 Jul 2026 12:11:07 +0530 Subject: [PATCH 01/12] peel L1: display-neutral view model (PeelView, statuses, attention comparator) Adds freshdata/render/view.py: PeelView/Metric/AttentionItem/Section frozen dataclasses, the shared status and severity vocabularies, the attention comparator (privacy > corruption > policy > reliability > cosmetic, then severity, count, name, stable id), and the confidence ladder. Pure stdlib, nothing imports it yet. Co-Authored-By: Claude Fable 5 --- src/freshdata/render/view.py | 134 +++++++++++++++++++++++++++++++++++ tests/test_peel_view.py | 125 ++++++++++++++++++++++++++++++++ 2 files changed, 259 insertions(+) create mode 100644 src/freshdata/render/view.py create mode 100644 tests/test_peel_view.py diff --git a/src/freshdata/render/view.py b/src/freshdata/render/view.py new file mode 100644 index 0000000..49cfdab --- /dev/null +++ b/src/freshdata/render/view.py @@ -0,0 +1,134 @@ +"""Peel view model — the display-neutral middle layer of freshdata's output system. + +Every report object is *normalized* (see :mod:`freshdata.render.normalize`) into a +:class:`PeelView`, and every renderer (plain text, rich terminal, notebook HTML) +consumes only views. Normalization is pure and deterministic: the same report +always produces the same view, so renderers never re-derive or re-rank anything. + +The vocabulary here is the single source of truth for Peel's shared grammar: + +* result **statuses** (``CLEAN``/``CHANGED``/``REVIEW``/``BLOCKED``/``PARTIAL``/ + ``SKIPPED``/``FAILED``) — always rendered as text labels, never color alone; +* finding **severities** (``error``/``warning``/``review``/``info``) with one + shared ranking used by every attention list; +* the **confidence ladder** mapping scores to plain-language phrases. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Callable + +#: Result statuses, in display order. A view may carry several (e.g. CHANGED+REVIEW). +STATUSES = ("CLEAN", "CHANGED", "REVIEW", "BLOCKED", "PARTIAL", "SKIPPED", "FAILED") + +#: Finding severities, most urgent first. +SEVERITIES = ("error", "warning", "review", "info") + +#: Attention-domain ranks (spec §5.3): privacy/safety outrank everything, +#: cosmetic consistency comes last. Normalizers tag each item with one. +DOMAINS = ("privacy", "corruption", "policy", "reliability", "cosmetic") + +_SEVERITY_RANK = {name: i for i, name in enumerate(SEVERITIES)} +_DOMAIN_RANK = {name: i for i, name in enumerate(DOMAINS)} + + +def confidence_phrase(confidence: float, *, ambiguous: bool = False) -> str: + """The plain-language rung of the confidence ladder for *confidence*. + + ``ambiguous=True`` forces the bottom rung regardless of score (a near-tie + between candidates is ambiguous even when the winner scored well). + """ + if ambiguous or confidence < 0.60: + return "ambiguous — no change made" if ambiguous else "uncertain" + if confidence >= 0.95: + return "strong evidence" + if confidence >= 0.80: + return "moderate evidence" + return "uncertain" + + +@dataclass(frozen=True) +class Metric: + """One glance-layer number, optionally as a before/after pair.""" + + label: str + value: str + before: str | None = None + after: str | None = None + + +@dataclass(frozen=True) +class AttentionItem: + """One ranked finding in the attention list. + + ``id`` is stable within a run (``W1``, ``R2``, ``D1``, ...) so terminal + output, notebook output, and programmatic access all name the same thing. + """ + + id: str + severity: str # one of SEVERITIES + subject: str # column / frame / finding target ("" for table-level) + text: str # plain-language, one sentence + domain: str = "reliability" # one of DOMAINS + count: int = 0 # affected cells/rows (0 = not applicable) + detail: dict[str, Any] = field(default_factory=dict) # audit-layer payload + + def sort_key(self) -> tuple[int, int, int, str, str, str]: + """Shared comparator (spec §5.3): domain, severity, -count, name order, + then the stable id so ranking is total (independent of input order).""" + return ( + _DOMAIN_RANK.get(self.domain, len(DOMAINS)), + _SEVERITY_RANK.get(self.severity, len(SEVERITIES)), + -self.count, + self.subject, + self.text, + self.id, + ) + + +@dataclass(frozen=True) +class Section: + """One inspect-layer group. ``body`` is a thunk so detail costs nothing + until a renderer actually expands it (spec §16).""" + + key: str # machine name, e.g. "columns", "actions", "audit" + title: str # human name, e.g. "Column changes" + body: Callable[[], list[dict[str, Any]]] # lazy rows, JSON-friendly + count: int = 0 # advertised size without evaluating body + + def rows(self) -> list[dict[str, Any]]: + return self.body() + + +@dataclass(frozen=True) +class PeelView: + """The display-neutral shape of one report (spec §12.2). + + ``audit_ref`` is the original report object — layer 3 is never a rendering. + """ + + kind: str # "clean_report", "parse", "copilot", ... + status: tuple[str, ...] # subset of STATUSES, display order + headline: str # one-sentence impact + metrics: tuple[Metric, ...] + attention: tuple[AttentionItem, ...] # already ranked + next_step: str | None # one runnable snippet, or None + sections: tuple[Section, ...] + audit_ref: Any = None + banner: str | None = None # PARTIAL/experimental banner text, if any + + def __post_init__(self) -> None: + unknown = [s for s in self.status if s not in STATUSES] + if unknown: + raise ValueError(f"unknown status labels: {unknown}") + + @property + def status_label(self) -> str: + """The combined text label, e.g. ``"CHANGED · REVIEW"``.""" + return " · ".join(self.status) + + +def rank_attention(items: list[AttentionItem]) -> tuple[AttentionItem, ...]: + """Sort *items* by the shared comparator. Deterministic; stable for ties.""" + return tuple(sorted(items, key=AttentionItem.sort_key)) diff --git a/tests/test_peel_view.py b/tests/test_peel_view.py new file mode 100644 index 0000000..d59db64 --- /dev/null +++ b/tests/test_peel_view.py @@ -0,0 +1,125 @@ +"""Layer 1: the Peel view model (freshdata.render.view).""" + +from __future__ import annotations + +import pytest + +from freshdata.render.view import ( + DOMAINS, + SEVERITIES, + STATUSES, + AttentionItem, + Metric, + PeelView, + Section, + confidence_phrase, + rank_attention, +) + + +class TestVocabulary: + def test_status_labels(self): + assert STATUSES == ( + "CLEAN", + "CHANGED", + "REVIEW", + "BLOCKED", + "PARTIAL", + "SKIPPED", + "FAILED", + ) + + def test_severities_most_urgent_first(self): + assert SEVERITIES == ("error", "warning", "review", "info") + + def test_domains_privacy_first_cosmetic_last(self): + assert DOMAINS[0] == "privacy" + assert DOMAINS[-1] == "cosmetic" + + +class TestConfidencePhrase: + @pytest.mark.parametrize( + ("value", "phrase"), + [ + (1.0, "strong evidence"), + (0.95, "strong evidence"), + (0.94, "moderate evidence"), + (0.80, "moderate evidence"), + (0.79, "uncertain"), + (0.60, "uncertain"), + (0.59, "uncertain"), + (0.10, "uncertain"), + ], + ) + def test_ladder(self, value, phrase): + assert confidence_phrase(value) == phrase + + def test_ambiguous_overrides_score(self): + assert confidence_phrase(0.91, ambiguous=True) == "ambiguous — no change made" + + +class TestAttentionRanking: + def test_domain_outranks_severity(self): + privacy_info = AttentionItem("A1", "info", "email", "pii", domain="privacy") + cosmetic_error = AttentionItem("A2", "error", "name", "case", domain="cosmetic") + assert rank_attention([cosmetic_error, privacy_info])[0] is privacy_info + + def test_severity_then_count_then_name(self): + a = AttentionItem("1", "warning", "b", "x", count=5) + b = AttentionItem("2", "warning", "a", "x", count=5) + c = AttentionItem("3", "warning", "z", "x", count=99) + d = AttentionItem("4", "error", "z", "x", count=0) + assert [i.id for i in rank_attention([a, b, c, d])] == ["4", "3", "2", "1"] + + def test_deterministic(self): + items = [ + AttentionItem(str(i), "info", f"col{i % 3}", "t", count=i % 2) for i in range(10) + ] + assert rank_attention(list(items)) == rank_attention(list(reversed(items))) + + def test_unknown_domain_and_severity_sort_last(self): + odd = AttentionItem("1", "bizarre", "a", "x", domain="martian") + known = AttentionItem("2", "info", "a", "x", domain="cosmetic") + assert rank_attention([odd, known])[-1] is odd + + +class TestPeelView: + def _view(self, **kw): + defaults = { + "kind": "clean_report", + "status": ("CHANGED", "REVIEW"), + "headline": "9,988 of 10,000 rows kept", + "metrics": (Metric("missing", "0", before="1,204", after="0"),), + "attention": (), + "next_step": None, + "sections": (), + } + defaults.update(kw) + return PeelView(**defaults) + + def test_status_label_joined(self): + assert self._view().status_label == "CHANGED · REVIEW" + + def test_rejects_unknown_status(self): + with pytest.raises(ValueError, match="unknown status"): + self._view(status=("CHANGED", "AMAZING")) + + def test_frozen(self): + with pytest.raises(AttributeError): + self._view().kind = "other" + + def test_section_body_is_lazy(self): + calls = [] + + def body(): + calls.append(1) + return [{"a": 1}] + + s = Section("actions", "All actions", body, count=1) + assert calls == [] + assert s.rows() == [{"a": 1}] + assert calls == [1] + + def test_audit_ref_is_identity(self): + report = object() + assert self._view(audit_ref=report).audit_ref is report From 9fda5e2f5046b436656d814806ccfea1bf246c50 Mon Sep 17 00:00:00 2001 From: Johnny Wilson Dougherty Date: Sun, 12 Jul 2026 12:26:41 +0530 Subject: [PATCH 02/12] peel L2: CleanReport normalizer and plain-language vocabulary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit normalize.py turns a CleanReport into a PeelView: status derivation (CLEAN/CHANGED + REVIEW + PARTIAL), ranked attention items with stable per-source ids (W/R/S/D/C/F), glance metrics, a single recommended next step, and lazy inspect sections (per-column aggregation, full action log, audit fields — undo_log excluded as ever). register_normalizer() gives third-party report kinds the same path. _vocabulary.py centralizes the display-language mapping; audit output keeps exact terms. Co-Authored-By: Claude Fable 5 --- src/freshdata/render/_vocabulary.py | 48 +++++ src/freshdata/render/normalize.py | 300 ++++++++++++++++++++++++++++ tests/test_peel_normalize.py | 257 ++++++++++++++++++++++++ 3 files changed, 605 insertions(+) create mode 100644 src/freshdata/render/_vocabulary.py create mode 100644 src/freshdata/render/normalize.py create mode 100644 tests/test_peel_normalize.py diff --git a/src/freshdata/render/_vocabulary.py b/src/freshdata/render/_vocabulary.py new file mode 100644 index 0000000..7b0f09f --- /dev/null +++ b/src/freshdata/render/_vocabulary.py @@ -0,0 +1,48 @@ +"""Peel's plain-language vocabulary (spec §14). + +Display layers 1-2 speak user language; the audit layer keeps exact technical +terms. This module is the single place display wording is defined so every +renderer stays consistent. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: # pragma: no cover - typing only + from ..report import Action + +#: step name → plain-language verb phrase, used when summarizing per column. +_STEP_PHRASES = { + "missing": "filled missing values", + "impute": "filled missing values", + "outliers": "extreme values adjusted", + "duplicates": "removed duplicate rows", + "fix_dtypes": "fixed value types", + "whitespace": "trimmed whitespace", + "semantic": "standardized values", +} + +#: audit/internal term → display phrase (spec §14.2), applied to prose. +TERMS = { + "backend abstained": "no safe match was found", + "fallback event": "FreshData continued without the optional engine/model", + "calibration unavailable": "confidence could not be independently adjusted", + "constraint violation": "this value breaks a rule you defined", + "residual values": "values not resolved by earlier checks", + "not materialized": "result kept in the engine", +} + + +def plain_step(action: Action) -> str: + """A short plain-language phrase for *action*, for per-column summaries. + + Falls back to the action's own description — descriptions are already + human sentences; the map only replaces the jargon-heavy step families. + """ + phrase = _STEP_PHRASES.get(action.step) + if phrase is None: + return action.description + if action.count: + return f"{phrase} ({action.count:,})" + return phrase diff --git a/src/freshdata/render/normalize.py b/src/freshdata/render/normalize.py new file mode 100644 index 0000000..80db820 --- /dev/null +++ b/src/freshdata/render/normalize.py @@ -0,0 +1,300 @@ +"""Report objects → :class:`~freshdata.render.view.PeelView`. + +One pure function per ``_render_kind``. Normalizers read only the report +object — never a DataFrame — and are deterministic: the same report always +yields the same view, including attention ordering and finding ids. + +Finding-id prefixes are per source so terminal, notebook, and programmatic +access all name the same thing: + +``W`` report warnings · ``R`` recommendations · ``S`` suggested/review actions +· ``D`` domain findings · ``C`` contract findings · ``F`` fallback events +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Callable + +from ._vocabulary import plain_step +from .view import AttentionItem, Metric, PeelView, Section, rank_attention + +if TYPE_CHECKING: # pragma: no cover - typing only + from ..report import CleanReport + +#: Normalizers by ``_render_kind``; extended by later layers (parse, copilot). +_NORMALIZERS: dict[str, Callable[[Any], PeelView]] = {} + + +def register_normalizer(kind: str, fn: Callable[[Any], PeelView]) -> None: + """Register *fn* as the normalizer for ``_render_kind == kind``. + + Third-party report objects use this the same way built-ins do (spec §12.3). + """ + _NORMALIZERS[kind] = fn + + +def normalize(obj: Any) -> PeelView: + """Return the PeelView for *obj*, dispatching on its ``_render_kind``.""" + kind = getattr(obj, "_render_kind", "") + fn = _NORMALIZERS.get(kind) + if fn is None: + raise KeyError(f"no Peel normalizer registered for kind {kind!r}") + return fn(obj) + + +# -- clean_report ------------------------------------------------------------ + + +def _fmt_duration(seconds: float) -> str: + return f"{seconds:.1f}s" if seconds >= 0.05 else f"{seconds * 1000:.0f}ms" + + +def _headline(rep: CleanReport) -> str: + if not rep.materialized: + return ( + f"{rep.rows_before:,} rows in · result kept in the engine " + f"(counts not computed to avoid a full scan) · {_fmt_duration(rep.duration_seconds)}" + ) + parts = [f"{rep.rows_after:,} of {rep.rows_before:,} rows kept"] + if rep.cols_after != rep.cols_before: + parts.append(f"{rep.cols_before:,} → {rep.cols_after:,} columns") + else: + parts.append(f"{rep.cols_after:,} columns") + parts.append(f"{rep.cells_changed:,} cells changed") + parts.append(_fmt_duration(rep.duration_seconds)) + return " · ".join(parts) + + +def _statuses(rep: CleanReport, attention: tuple[AttentionItem, ...]) -> tuple[str, ...]: + statuses = ["CHANGED" if bool(rep) else "CLEAN"] + if any(a.severity in ("error", "warning", "review") for a in attention): + statuses.append("REVIEW") + if not rep.materialized: + statuses.append("PARTIAL") + return tuple(statuses) + + +def _metrics(rep: CleanReport) -> tuple[Metric, ...]: + if not rep.materialized: + return (Metric("rows in", f"{rep.rows_before:,}"),) + metrics = [ + Metric( + "missing", + f"{rep.missing_after:,}", + before=f"{rep.missing_before:,}", + after=f"{rep.missing_after:,}", + ) + ] + if rep.duplicates_removed: + metrics.append(Metric("duplicates", f"-{rep.duplicates_removed:,}")) + if rep.outliers_handled: + metrics.append(Metric("extreme values adjusted", f"{rep.outliers_handled:,}")) + if rep.columns_preserved: + metrics.append(Metric("protected", f"{len(rep.columns_preserved)}")) + if rep.columns_dropped: + metrics.append(Metric("columns dropped", f"{len(rep.columns_dropped)}")) + return tuple(metrics) + + +def _clean_attention(rep: CleanReport) -> tuple[AttentionItem, ...]: + items: list[AttentionItem] = [] + + for i, message in enumerate(rep.warnings, 1): + items.append( + AttentionItem( + id=f"W{i}", + severity="warning", + subject="", + text=message, + domain="reliability", + detail={"source": "warnings"}, + ) + ) + for i, message in enumerate(rep.recommendations, 1): + items.append( + AttentionItem( + id=f"R{i}", + severity="review", + subject="", + text=message, + domain="reliability", + detail={"source": "recommendations"}, + ) + ) + + suggested = [ + a for a in rep.actions if a.status == "suggested" or (a.human_review and a.count) + ] + for i, action in enumerate(suggested, 1): + items.append( + AttentionItem( + id=f"S{i}", + severity="review", + subject=action.column or "", + text=f"{action.description} — held for your review", + domain="corruption" if action.risk == "high" else "reliability", + count=action.count, + detail={"source": "actions", "action": rep._action_dict(action)}, + ) + ) + + for i, finding in enumerate(rep.domain_findings, 1): + if finding.get("status") != "violated": + continue + severity = "error" if finding.get("severity") == "error" else "warning" + items.append( + AttentionItem( + id=f"D{i}", + severity=severity, + subject=str(finding.get("column") or finding.get("rule") or ""), + text=str(finding.get("message") or finding.get("rule") or "domain rule violated"), + domain="policy", + count=int(finding.get("count") or 0), + detail={"source": "domain_findings", "finding": finding}, + ) + ) + + cv = rep.contract_violations + if cv is not None and not cv.get("passed", True): + for i, finding in enumerate(cv.get("findings", []), 1): + if finding.get("status") == "passed": + continue + severity = "error" if finding.get("status") == "failed" else "warning" + items.append( + AttentionItem( + id=f"C{i}", + severity=severity, + subject=str(finding.get("column") or ""), + text=( + f"contract '{cv.get('baseline_name')}': " + f"{finding.get('message') or finding.get('check_id')}" + ), + domain="policy", + detail={"source": "contract_violations", "finding": finding}, + ) + ) + + for i, event in enumerate(rep.fallback_events, 1): + items.append( + AttentionItem( + id=f"F{i}", + severity="info", + subject=str(event.get("fallback_step") or ""), + text=( + "FreshData continued without the optional engine step " + f"({event.get('backend')}: {event.get('fallback_reason')})" + ), + domain="reliability", + detail={"source": "fallback_events", "event": event}, + ) + ) + + return rank_attention(items) + + +def _next_step(rep: CleanReport, attention: tuple[AttentionItem, ...]) -> str | None: + if not attention or attention[0].severity == "info": + return None + top = attention[0] + source = top.detail.get("source") + if source == "contract_violations": + return "report.contract_violations # review contract failures" + if source == "domain_findings": + return "report.domain_findings # review domain rule violations" + if source == "actions": + return "fd.suggest_plan(df) # review and approve the suggested changes" + return "fd.explain_clean(df) # see the evidence behind each decision" + + +def _column_rows(rep: CleanReport) -> list[dict[str, Any]]: + """Per-column aggregation of the action log, most-changed first.""" + by_column: dict[str, dict[str, Any]] = {} + risk_rank = {"low": 0, "medium": 1, "high": 2} + for action in rep.actions: + key = action.column if action.column is not None else "(table)" + row = by_column.setdefault( + key, {"column": key, "changed": 0, "what": [], "risk": "low"} + ) + row["changed"] += action.count + row["what"].append(plain_step(action)) + if risk_rank.get(action.risk, 0) > risk_rank.get(row["risk"], 0): + row["risk"] = action.risk + for name in rep.columns_preserved: + row = by_column.setdefault( + name, {"column": name, "changed": 0, "what": [], "risk": "low"} + ) + row["protected"] = True + rows = list(by_column.values()) + rows.sort(key=lambda r: (-r["changed"], r["column"])) + for row in rows: + row["what"] = "; ".join(dict.fromkeys(row["what"])) or ( + "protected — left untouched" if row.get("protected") else "" + ) + return rows + + +def _audit_rows(rep: CleanReport) -> list[dict[str, Any]]: + keys = ( + "backend", + "materialized", + "duration_seconds", + "memory_before", + "memory_after", + "fallback_events", + "backend_differences", + "stage_timings", + "domain", + "domain_trust_score", + "domain_repairs", + "streaming", + "profile_replay", + "source_provenance", + "contract_violations", + "decisions_hash", + ) + rows = [] + for key in keys: + value = getattr(rep, key) + if value not in (None, [], {}): + rows.append({"field": key, "value": value}) + return rows + + +def normalize_clean_report(rep: CleanReport) -> PeelView: + """Normalize a :class:`~freshdata.report.CleanReport` (spec §6).""" + attention = _clean_attention(rep) + banner = None + if not rep.materialized: + banner = ( + "PARTIAL — result kept in the engine; row counts not computed " + "to avoid a full scan. Call .fetchdf()/.collect() to pull rows." + ) + sections = ( + Section( + "columns", + "Column changes", + lambda: _column_rows(rep), + count=len({a.column for a in rep.actions if a.column is not None}), + ), + Section( + "actions", + "All actions", + lambda: [rep._action_dict(a) for a in rep.actions], + count=len(rep.actions), + ), + Section("audit", "Audit", lambda: _audit_rows(rep), count=len(_audit_rows(rep))), + ) + return PeelView( + kind="clean_report", + status=_statuses(rep, attention), + headline=_headline(rep), + metrics=_metrics(rep), + attention=attention, + next_step=_next_step(rep, attention), + sections=sections, + audit_ref=rep, + banner=banner, + ) + + +register_normalizer("clean_report", normalize_clean_report) diff --git a/tests/test_peel_normalize.py b/tests/test_peel_normalize.py new file mode 100644 index 0000000..49e81f6 --- /dev/null +++ b/tests/test_peel_normalize.py @@ -0,0 +1,257 @@ +"""Layer 2: CleanReport → PeelView normalization.""" + +from __future__ import annotations + +import pytest + +from freshdata.render.normalize import normalize, normalize_clean_report, register_normalizer +from freshdata.render.view import PeelView +from freshdata.report import CleanReport + + +def make_report(**kw) -> CleanReport: + rep = CleanReport( + rows_before=10_000, + rows_after=9_988, + cols_before=14, + cols_after=14, + missing_before=1_204, + missing_after=0, + duration_seconds=0.412, + ) + for key, value in kw.items(): + setattr(rep, key, value) + return rep + + +class TestDispatch: + def test_normalize_dispatches_on_render_kind(self): + view = normalize(make_report()) + assert isinstance(view, PeelView) + assert view.kind == "clean_report" + + def test_unknown_kind_raises(self): + with pytest.raises(KeyError, match="no Peel normalizer"): + normalize(object()) + + def test_register_normalizer_extends_dispatch(self): + class Custom: + _render_kind = "custom_thing" + + view = PeelView("custom_thing", ("CLEAN",), "ok", (), (), None, ()) + register_normalizer("custom_thing", lambda obj: view) + assert normalize(Custom()) is view + + +class TestStatus: + def test_untouched_data_is_clean(self): + assert normalize_clean_report(make_report()).status == ("CLEAN",) + + def test_changes_make_changed(self): + rep = make_report() + rep.add("missing", "filled 214 values", column="age", count=214) + assert normalize_clean_report(rep).status == ("CHANGED",) + + def test_warnings_add_review(self): + rep = make_report(warnings=["column 'income' is 38% missing"]) + assert normalize_clean_report(rep).status == ("CLEAN", "REVIEW") + + def test_unmaterialized_adds_partial_and_banner(self): + rep = make_report(materialized=False) + view = normalize_clean_report(rep) + assert "PARTIAL" in view.status + assert "kept in the engine" in view.banner + assert "kept in the engine" in view.headline + + def test_info_only_attention_is_not_review(self): + rep = make_report( + fallback_events=[ + {"backend": "polars", "fallback_step": "impute", "fallback_reason": "dtype"} + ] + ) + view = normalize_clean_report(rep) + assert "REVIEW" not in view.status + assert view.attention[0].severity == "info" + + +class TestAttention: + def test_ids_are_stable_per_source(self): + rep = make_report( + warnings=["w-one", "w-two"], + recommendations=["r-one"], + ) + view = normalize_clean_report(rep) + assert [a.id for a in view.attention] == ["W1", "W2", "R1"] + assert [a.severity for a in view.attention] == ["warning", "warning", "review"] + + def test_domain_errors_outrank_report_warnings(self): + rep = make_report( + warnings=["some warning"], + domain="healthcare", + domain_findings=[ + { + "status": "violated", + "severity": "error", + "column": "obx_units", + "message": "47 values are not valid UCUM units", + "count": 47, + } + ], + ) + top = normalize_clean_report(rep).attention[0] + assert top.id == "D1" + assert top.severity == "error" + assert top.domain == "policy" + assert top.count == 47 + + def test_passed_domain_findings_are_not_attention(self): + rep = make_report( + domain="energy", + domain_findings=[{"status": "passed", "severity": "error", "rule": "range"}], + ) + assert normalize_clean_report(rep).attention == () + + def test_suggested_actions_become_review_items(self): + rep = make_report() + rep.add( + "semantic", + "proposed 'Germany' for 'Germny'", + column="country", + count=1, + status="suggested", + confidence=0.84, + ) + view = normalize_clean_report(rep) + (item,) = view.attention + assert item.id == "S1" + assert item.severity == "review" + assert item.subject == "country" + assert "held for your review" in item.text + assert item.detail["action"]["confidence"] == 0.84 + + def test_contract_failures_become_policy_items(self): + rep = make_report( + contract_violations={ + "passed": False, + "baseline_name": "orders", + "baseline_version": 3, + "findings": [ + {"status": "failed", "column": "amount", "message": "dtype drift", + "check_id": "dtype"}, + {"status": "passed", "column": "id", "check_id": "presence"}, + ], + } + ) + view = normalize_clean_report(rep) + (item,) = view.attention + assert item.id == "C1" + assert item.severity == "error" + assert item.domain == "policy" + assert "contract 'orders'" in item.text + + def test_fallback_events_are_plain_language_info(self): + rep = make_report( + fallback_events=[ + {"backend": "duckdb", "fallback_step": "outliers", "fallback_reason": "quantile"} + ] + ) + (item,) = normalize_clean_report(rep).attention + assert item.id == "F1" + assert "continued without the optional engine step" in item.text + + def test_deterministic_across_calls(self): + rep = make_report(warnings=["a", "b"], recommendations=["c"]) + first = normalize_clean_report(rep) + second = normalize_clean_report(rep) + assert first.attention == second.attention + assert first.status == second.status + assert first.headline == second.headline + + +class TestHeadlineAndMetrics: + def test_headline_contents(self): + rep = make_report() + rep.add("missing", "filled", column="age", count=214) + headline = normalize_clean_report(rep).headline + assert "9,988 of 10,000 rows kept" in headline + assert "14 columns" in headline + assert "214 cells changed" in headline + assert "0.4s" in headline + + def test_metrics_include_missing_and_protected(self): + rep = make_report(duplicates_removed=12, columns_preserved=["notes"]) + labels = {m.label: m for m in normalize_clean_report(rep).metrics} + assert labels["missing"].before == "1,204" + assert labels["missing"].after == "0" + assert labels["duplicates"].value == "-12" + assert labels["protected"].value == "1" + + +class TestNextStep: + def test_no_attention_no_next_step(self): + assert normalize_clean_report(make_report()).next_step is None + + def test_info_only_no_next_step(self): + rep = make_report( + fallback_events=[{"backend": "polars", "fallback_step": "x", "fallback_reason": "y"}] + ) + assert normalize_clean_report(rep).next_step is None + + def test_warning_points_to_explain(self): + rep = make_report(warnings=["income is 38% missing"]) + assert "explain_clean" in normalize_clean_report(rep).next_step + + def test_suggested_actions_point_to_plan(self): + rep = make_report() + rep.add("semantic", "proposed fix", column="c", count=2, status="suggested") + assert "suggest_plan" in normalize_clean_report(rep).next_step + + def test_domain_findings_win_over_warnings(self): + rep = make_report( + warnings=["w"], + domain="finance", + domain_findings=[ + {"status": "violated", "severity": "error", "column": "px", "message": "bad"} + ], + ) + assert "domain_findings" in normalize_clean_report(rep).next_step + + +class TestSections: + def test_column_rows_aggregate_and_order(self): + rep = make_report(columns_preserved=["notes"]) + rep.add("missing", "filled 214 value(s) with median", column="age", count=214) + rep.add("outliers", "winsorized 37 value(s)", column="income", count=37) + rep.add("missing", "filled 380 value(s) with median", column="income", count=380) + view = normalize_clean_report(rep) + columns = next(s for s in view.sections if s.key == "columns") + rows = columns.rows() + assert rows[0]["column"] == "income" + assert rows[0]["changed"] == 417 + assert rows[0]["risk"] == "low" + assert "filled missing values" in rows[0]["what"] + assert "extreme values adjusted" in rows[0]["what"] + protected = next(r for r in rows if r["column"] == "notes") + assert protected["what"] == "protected — left untouched" + + def test_actions_section_uses_report_schema(self): + rep = make_report() + rep.add("missing", "filled", column="age", count=214, rationale="median safe") + actions = next(s for s in normalize_clean_report(rep).sections if s.key == "actions") + assert actions.count == 1 + (row,) = actions.rows() + assert row["step"] == "missing" + assert row["rationale"] == "median safe" + + def test_audit_section_skips_empty_and_keeps_hash(self): + rep = make_report(decisions_hash="abc123", backend="polars") + audit = next(s for s in normalize_clean_report(rep).sections if s.key == "audit") + fields = {r["field"]: r["value"] for r in audit.rows()} + assert fields["decisions_hash"] == "abc123" + assert fields["backend"] == "polars" + assert "streaming" not in fields + assert "undo_log" not in fields # never serialized (spec §13) + + def test_audit_ref_is_the_report(self): + rep = make_report() + assert normalize_clean_report(rep).audit_ref is rep From 547e5936df9b8d42f1606d08e2d52196028840c7 Mon Sep 17 00:00:00 2001 From: Johnny Wilson Dougherty Date: Sun, 12 Jul 2026 12:33:14 +0530 Subject: [PATCH 03/12] peel L3: display options and plain-text reference renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit options.py: RenderOptions + set_display/get_display/reset_display with the documented precedence (explicit args > set_display > FRESHDATA_DISPLAY / NO_COLOR / FRESHDATA_NO_PREVIEWS > defaults); auto mode resolves by TTY. plain.py: the reference renderer — compact/standard/verbose/debug/json/ plain/silent modes over PeelView, zero ANSI, grep-able status labels, explicit empty-attention state, ASCII transliteration in plain mode. Co-Authored-By: Claude Fable 5 --- src/freshdata/render/options.py | 76 ++++++++++++++ src/freshdata/render/plain.py | 148 ++++++++++++++++++++++++++ tests/test_peel_plain.py | 181 ++++++++++++++++++++++++++++++++ 3 files changed, 405 insertions(+) create mode 100644 src/freshdata/render/options.py create mode 100644 src/freshdata/render/plain.py create mode 100644 tests/test_peel_plain.py diff --git a/src/freshdata/render/options.py b/src/freshdata/render/options.py new file mode 100644 index 0000000..3aed667 --- /dev/null +++ b/src/freshdata/render/options.py @@ -0,0 +1,76 @@ +"""Display configuration for Peel renderers (spec §11). + +Display options never affect cleaning behavior — they are consumed only by +renderers. Precedence: explicit arguments > process-wide :func:`set_display` +> environment (``FRESHDATA_DISPLAY``, ``NO_COLOR``, ``FRESHDATA_NO_PREVIEWS``) +> defaults. +""" + +from __future__ import annotations + +import os +import sys +from dataclasses import dataclass, replace + +#: Valid display modes (spec §11.1). +MODES = ("auto", "compact", "standard", "verbose", "debug", "json", "plain", "silent") + + +@dataclass(frozen=True) +class RenderOptions: + """Resolved display options handed to a renderer.""" + + mode: str = "auto" + color: str = "auto" # "auto" | "always" | "never" + width: int = 74 + previews: bool = True # False → schema-only display, values never shown + ascii_icons: bool = False + + def resolved_mode(self, *, isatty: bool | None = None) -> str: + """Collapse ``auto`` to a concrete mode for the current environment.""" + if self.mode != "auto": + return self.mode + if isatty is None: + isatty = sys.stdout.isatty() + return "standard" if isatty else "compact" + + +_state = {"options": RenderOptions()} + + +def set_display(**changes: object) -> RenderOptions: + """Set process-wide display preferences, e.g. ``fd.set_display(mode="compact")``. + + Returns the new options. Unknown fields raise ``TypeError``; an unknown + mode raises ``ValueError`` so typos fail at configuration time, not render + time. + """ + new = replace(_state["options"], **changes) # type: ignore[arg-type] + if new.mode not in MODES: + raise ValueError(f"unknown display mode {new.mode!r}; expected one of {MODES}") + _state["options"] = new + return new + + +def get_display(**overrides: object) -> RenderOptions: + """The effective options: defaults ← env ← :func:`set_display` ← *overrides*.""" + opts = _state["options"] + env_mode = os.environ.get("FRESHDATA_DISPLAY") + if env_mode and opts.mode == "auto" and env_mode in MODES: + opts = replace(opts, mode=env_mode) + if os.environ.get("NO_COLOR"): + opts = replace(opts, color="never") + if os.environ.get("FRESHDATA_NO_PREVIEWS"): + opts = replace(opts, previews=False) + if overrides: + opts = replace(opts, **overrides) # type: ignore[arg-type] + if opts.mode not in MODES: + raise ValueError(f"unknown display mode {opts.mode!r}; expected one of {MODES}") + if opts.mode == "plain": + opts = replace(opts, color="never", ascii_icons=True) + return opts + + +def reset_display() -> None: + """Restore defaults (used by tests and ``FRESHDATA_LEGACY_DISPLAY`` flows).""" + _state["options"] = RenderOptions() diff --git a/src/freshdata/render/plain.py b/src/freshdata/render/plain.py new file mode 100644 index 0000000..c22df36 --- /dev/null +++ b/src/freshdata/render/plain.py @@ -0,0 +1,148 @@ +"""Plain-text Peel renderer — no ANSI, works everywhere (spec §11). + +This is the reference renderer: the rich terminal renderer and the notebook +renderer must present the same information; only styling may differ. Because +every state is a text label, output stays grep-able in logs and CI +(``grep REVIEW``, ``grep PARTIAL``). +""" + +from __future__ import annotations + +import json + +from .options import RenderOptions, get_display +from .view import PeelView, Section + +#: view.kind → command-style title. +_TITLES = { + "clean_report": "freshdata clean", + "profile": "freshdata profile", + "parse": "freshdata parse", + "copilot": "freshdata ai-copilot", +} + +_SEVERITY_LABELS = { + "error": "Error", + "warning": "Warning", + "review": "Review", + "info": "Info", +} + + +def _title(view: PeelView) -> str: + return _TITLES.get(view.kind, f"freshdata {view.kind}") + + +def _sep(options: RenderOptions) -> str: + return " - " if options.ascii_icons else " · " + + +def _status_text(view: PeelView, options: RenderOptions) -> str: + return (" - " if options.ascii_icons else " · ").join(view.status) + + +def _rule(text_left: str, text_right: str, options: RenderOptions) -> str: + dash = "-" if options.ascii_icons else "─" + body = f"{dash * 2} {text_left} " + right = f" {text_right} {dash * 2}" if text_right else dash * 2 + fill = max(1, options.width - len(body) - len(right)) + return body + dash * fill + right + + +def _attention_lines(view: PeelView, options: RenderOptions) -> list[str]: + if not view.attention: + return [" nothing needs review"] + lines = [f" Needs attention ({len(view.attention)})"] + subject_width = min(12, max((len(a.subject) for a in view.attention), default=0)) + for item in view.attention: + subject = item.subject[:subject_width].ljust(subject_width) + label = _SEVERITY_LABELS.get(item.severity, item.severity).ljust(8) + lines.append(f" {label} {subject} {item.text} [{item.id}]".rstrip()) + return lines + + +def _section_lines(section: Section) -> list[str]: + lines = [f" {section.title} ({section.count})"] + for row in section.rows(): + cells = ", ".join(f"{k}={_cell(v)}" for k, v in row.items() if v not in ("", None, [])) + lines.append(f" - {cells}") + return lines + + +def _cell(value: object) -> str: + if isinstance(value, float): + return f"{value:g}" + if isinstance(value, (dict, list)): + return json.dumps(value, default=str) + return str(value) + + +def render_plain(view: PeelView, options: RenderOptions | None = None) -> str: + """Render *view* as plain text in the mode carried by *options*.""" + options = options or get_display() + mode = options.resolved_mode() + + if mode == "silent": + return "" + if mode == "json": + report = view.audit_ref + if report is not None and hasattr(report, "to_dict"): + return json.dumps(report.to_dict(), indent=2, default=str) + return json.dumps({"kind": view.kind, "status": list(view.status)}, indent=2) + + sep = _sep(options) + + if mode == "compact": + pieces = [ + _title(view), + _status_text(view, options), + view.headline, + ] + if view.attention: + ids = " ".join(a.id for a in view.attention[:3]) + pieces.append(f"{len(view.attention)} attention ({ids})") + first = " ".join(pieces) + return first + "\n -> report.show() for details" + + # standard / verbose / debug / plain share the panel layout + lines = [_rule(_title(view), _status_text(view, options), options)] + lines.append(f" {view.headline}") + if view.metrics: + chips = [] + for metric in view.metrics: + if metric.before is not None: + arrow = "->" if options.ascii_icons else "→" + chips.append(f"{metric.label} {metric.before}{arrow}{metric.after}") + else: + chips.append(f"{metric.label} {metric.value}") + lines.append(" " + sep.join(chips).strip()) + if view.banner: + lines.append("") + lines.append(f" !! {view.banner}") + lines.append("") + lines.extend(_attention_lines(view, options)) + lines.append("") + if view.next_step: + lines.append(f" next {view.next_step}") + lines.append(f" more report.show(mode=\"verbose\"){sep}report.to_json()") + + if mode in ("verbose", "debug"): + for section in view.sections: + if section.key == "audit" and mode != "debug": + continue + lines.append("") + lines.extend(_section_lines(section)) + + dash = "-" if options.ascii_icons else "─" + lines.append(dash * options.width) + out = "\n".join(lines) + if options.ascii_icons: + out = _asciify(out) + return out + + +def _asciify(text: str) -> str: + """Replace Peel's framing glyphs with ASCII (plain mode / ASCII icon set).""" + for glyph, ascii_form in (("·", "-"), ("→", "->"), ("—", "--"), ("─", "-")): + text = text.replace(glyph, ascii_form) + return text diff --git a/tests/test_peel_plain.py b/tests/test_peel_plain.py new file mode 100644 index 0000000..5da5b6d --- /dev/null +++ b/tests/test_peel_plain.py @@ -0,0 +1,181 @@ +"""Layer 3: RenderOptions and the plain-text renderer.""" + +from __future__ import annotations + +import json + +import pytest + +from freshdata.render.normalize import normalize_clean_report +from freshdata.render.options import ( + MODES, + RenderOptions, + get_display, + reset_display, + set_display, +) +from freshdata.render.plain import render_plain +from freshdata.report import CleanReport + + +@pytest.fixture(autouse=True) +def _clean_display_state(monkeypatch): + for var in ("FRESHDATA_DISPLAY", "NO_COLOR", "FRESHDATA_NO_PREVIEWS"): + monkeypatch.delenv(var, raising=False) + reset_display() + yield + reset_display() + + +def make_view(**report_kw): + rep = CleanReport( + rows_before=10_000, + rows_after=9_988, + cols_before=14, + cols_after=14, + missing_before=1_204, + missing_after=0, + duration_seconds=0.412, + ) + for key, value in report_kw.items(): + setattr(rep, key, value) + rep.add("missing", "filled 214 value(s) with median", column="age", count=214) + return normalize_clean_report(rep) + + +class TestOptions: + def test_defaults(self): + opts = get_display() + assert opts.mode == "auto" + assert opts.color == "auto" + assert opts.previews is True + + def test_set_display_is_process_wide(self): + set_display(mode="compact") + assert get_display().mode == "compact" + + def test_set_display_rejects_unknown_mode(self): + with pytest.raises(ValueError, match="unknown display mode"): + set_display(mode="fancy") + + def test_env_mode_applies_when_auto(self, monkeypatch): + monkeypatch.setenv("FRESHDATA_DISPLAY", "verbose") + assert get_display().mode == "verbose" + + def test_set_display_beats_env(self, monkeypatch): + monkeypatch.setenv("FRESHDATA_DISPLAY", "verbose") + set_display(mode="compact") + assert get_display().mode == "compact" + + def test_overrides_beat_everything(self, monkeypatch): + monkeypatch.setenv("FRESHDATA_DISPLAY", "verbose") + set_display(mode="compact") + assert get_display(mode="debug").mode == "debug" + + def test_no_color_env(self, monkeypatch): + monkeypatch.setenv("NO_COLOR", "1") + assert get_display().color == "never" + + def test_no_previews_env(self, monkeypatch): + monkeypatch.setenv("FRESHDATA_NO_PREVIEWS", "1") + assert get_display().previews is False + + def test_plain_mode_implies_ascii_and_no_color(self): + opts = get_display(mode="plain") + assert opts.ascii_icons is True + assert opts.color == "never" + + def test_auto_resolves_by_tty(self): + opts = RenderOptions() + assert opts.resolved_mode(isatty=True) == "standard" + assert opts.resolved_mode(isatty=False) == "compact" + + def test_all_modes_are_known(self): + assert set(MODES) == { + "auto", "compact", "standard", "verbose", "debug", "json", "plain", "silent", + } + + +class TestPlainRenderer: + def test_silent_is_empty(self): + assert render_plain(make_view(), get_display(mode="silent")) == "" + + def test_json_mode_is_report_to_dict(self): + out = render_plain(make_view(), get_display(mode="json")) + payload = json.loads(out) + assert payload["rows_before"] == 10_000 + assert payload["actions"][0]["step"] == "missing" + + def test_compact_is_two_lines_with_pointer(self): + out = render_plain( + make_view(warnings=["income is 38% missing"]), + get_display(mode="compact"), + ) + lines = out.splitlines() + assert len(lines) == 2 + assert "freshdata clean" in lines[0] + assert "CHANGED" in lines[0] + assert "REVIEW" in lines[0] + assert "1 attention (W1)" in lines[0] + assert "report.show()" in lines[1] + + def test_standard_layout(self): + out = render_plain( + make_view(warnings=["income is 38% missing"]), + get_display(mode="standard"), + ) + assert "freshdata clean" in out + assert "CHANGED · REVIEW" in out + assert "9,988 of 10,000 rows kept" in out + assert "Needs attention (1)" in out + assert "Warning" in out and "[W1]" in out + assert "next fd.explain_clean" in out + # sections are not rendered in standard mode + assert "All actions" not in out + + def test_empty_attention_is_explicit(self): + out = render_plain(make_view(), get_display(mode="standard")) + assert "nothing needs review" in out + + def test_banner_rendered_prominently(self): + out = render_plain(make_view(materialized=False), get_display(mode="standard")) + assert "!! PARTIAL — result kept in the engine" in out + assert "PARTIAL" in out.splitlines()[0] + + def test_verbose_includes_sections_but_not_audit(self): + out = render_plain( + make_view(decisions_hash="abc123"), + get_display(mode="verbose"), + ) + assert "Column changes" in out + assert "All actions" in out + assert "filled missing values" in out + assert "abc123" not in out + + def test_debug_includes_audit(self): + out = render_plain( + make_view(decisions_hash="abc123", backend="polars"), + get_display(mode="debug"), + ) + assert "Audit" in out + assert "abc123" in out + assert "polars" in out + + def test_plain_mode_has_no_unicode_frame(self): + out = render_plain( + make_view(warnings=["w"]), + get_display(mode="plain"), + ) + for char in ("─", "·", "→"): + assert char not in out + assert "Needs attention" in out # content identical to standard + + def test_width_is_respected(self): + for width in (60, 100): + out = render_plain(make_view(), get_display(mode="standard", width=width)) + assert len(out.splitlines()[0]) == width + + def test_grepable_status_labels(self): + out = render_plain(make_view(warnings=["w"]), get_display(mode="standard")) + assert "REVIEW" in out # grep REVIEW works with no ANSI in the way + assert "\x1b[" not in out From da2b7dcd2e972c408685ed713da76ac4bb965a60 Mon Sep 17 00:00:00 2001 From: Johnny Wilson Dougherty Date: Sun, 12 Jul 2026 12:39:35 +0530 Subject: [PATCH 04/12] peel L4: show(mode=, renderer=) and fd.set_display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HtmlReprMixin.show gains additive mode/renderer parameters: no-arg calls keep the exact legacy behavior (inline HTML in Jupyter, temp .html file elsewhere); mode= or renderer="terminal" prints the Peel text rendering. The Peel path never raises — unknown kinds or bad modes fall back to summary()/repr. fd.set_display/get_display/reset_display exported. Co-Authored-By: Claude Fable 5 --- src/freshdata/__init__.py | 4 ++ src/freshdata/render/mixins.py | 40 ++++++++++-- tests/test_peel_show.py | 112 +++++++++++++++++++++++++++++++++ 3 files changed, 150 insertions(+), 6 deletions(-) create mode 100644 tests/test_peel_show.py diff --git a/src/freshdata/__init__.py b/src/freshdata/__init__.py index 8e13179..13bb16e 100644 --- a/src/freshdata/__init__.py +++ b/src/freshdata/__init__.py @@ -71,6 +71,7 @@ ) from .profile import ColumnProfile, Profile from .quality import QualityDebtGate, evaluate_quality_debt +from .render.options import get_display, reset_display, set_display from .repairplan import ( FrameSignature, PlanDriftError, @@ -155,6 +156,9 @@ "apply_field_policy", "clean_text", "clean_text_value", + "get_display", + "reset_display", + "set_display", "validate_fields", "cdc_profile", "apply_plan", diff --git a/src/freshdata/render/mixins.py b/src/freshdata/render/mixins.py index d35a44e..a43925f 100644 --- a/src/freshdata/render/mixins.py +++ b/src/freshdata/render/mixins.py @@ -30,13 +30,24 @@ def _repr_html_(self) -> str | None: except Exception: # pragma: no cover - display must never raise return None - def show(self) -> Any: - """Display in a notebook, or write an HTML file and return its path. - - In Jupyter/IPython this renders inline. Outside a notebook it writes a - standalone ``.html`` file to a temp location and returns the path, so the - same call works from scripts and the REPL. + def show(self, mode: str | None = None, *, renderer: str | None = None) -> Any: + """Display this report. + + With no arguments the behavior is unchanged: in Jupyter/IPython the + HTML renders inline; outside a notebook a standalone ``.html`` file is + written to a temp location and its path returned. + + ``mode`` (``"compact"``/``"standard"``/``"verbose"``/``"debug"``/ + ``"json"``/``"plain"``/``"silent"``) or ``renderer="terminal"`` selects + the Peel text output instead; ``renderer="notebook"`` keeps the HTML + path. Display never raises: on any failure the Peel path falls back to + the object's ``summary()``/``repr``. """ + if mode is not None or renderer == "terminal": + text = self._peel_text(mode) + if text: + print(text) + return None html = self.to_html() try: from ._optional import require @@ -61,6 +72,23 @@ def show(self) -> Any: print(f"freshdata: wrote {kind} report to {path}") return path + def _peel_text(self, mode: str | None) -> str: + """Peel plain-text rendering with the never-raise fallback chain.""" + try: + from . import normalize, plain + from .options import get_display + + options = get_display(mode=mode) if mode is not None else get_display() + return plain.render_plain(normalize.normalize(self), options) + except Exception: + summary = getattr(self, "summary", None) + if callable(summary): + try: + return str(summary()) + except Exception: # pragma: no cover - summary must not raise + pass + return repr(self) + class SimpleHtmlReport(HtmlReprMixin): """Base for report objects that build their own HTML from the primitives. diff --git a/tests/test_peel_show.py b/tests/test_peel_show.py new file mode 100644 index 0000000..5b16567 --- /dev/null +++ b/tests/test_peel_show.py @@ -0,0 +1,112 @@ +"""Layer 4: show(mode=, renderer=) integration and fd.set_display.""" + +from __future__ import annotations + +import json +import os +import tempfile + +import pandas as pd +import pytest + +import freshdata as fd +from freshdata.render.mixins import HtmlReprMixin +from freshdata.render.options import reset_display +from freshdata.report import CleanReport + + +@pytest.fixture(autouse=True) +def _clean_display_state(monkeypatch): + for var in ("FRESHDATA_DISPLAY", "NO_COLOR", "FRESHDATA_NO_PREVIEWS"): + monkeypatch.delenv(var, raising=False) + reset_display() + yield + reset_display() + + +def make_report() -> CleanReport: + rep = CleanReport( + rows_before=100, rows_after=99, cols_before=3, cols_after=3, duration_seconds=0.01 + ) + rep.add("duplicates", "removed 1 duplicate row", count=1) + rep.add_warning("column 'x' is mostly empty") + return rep + + +class TestShowModes: + def test_mode_prints_peel_text(self, capsys): + result = make_report().show(mode="standard") + out = capsys.readouterr().out + assert result is None + assert "freshdata clean" in out + assert "CHANGED · REVIEW" in out + assert "[W1]" in out + + def test_renderer_terminal_defaults_to_auto_mode(self, capsys): + make_report().show(renderer="terminal") + out = capsys.readouterr().out + assert "freshdata clean" in out # compact or standard depending on TTY + + def test_mode_silent_prints_nothing(self, capsys): + make_report().show(mode="silent") + assert capsys.readouterr().out == "" + + def test_mode_json_prints_to_dict(self, capsys): + make_report().show(mode="json") + payload = json.loads(capsys.readouterr().out) + assert payload["rows_before"] == 100 + + def test_set_display_governs_default_mode(self, capsys): + fd.set_display(mode="compact") + make_report().show(renderer="terminal") + out = capsys.readouterr().out + assert len(out.rstrip("\n").splitlines()) == 2 + + def test_no_args_keeps_legacy_html_file_behavior(self, capsys, tmp_path, monkeypatch): + monkeypatch.setenv("TMPDIR", str(tmp_path)) + tempfile.tempdir = None # re-read TMPDIR + try: + path = make_report().show() + assert path is not None and path.endswith(".html") + assert os.path.exists(path) + with open(path, encoding="utf-8") as fh: + assert "" in fh.read() + finally: + tempfile.tempdir = None + + +class TestFallbackSafety: + def test_unregistered_kind_falls_back_to_summary(self, capsys): + class Odd(HtmlReprMixin): + _render_kind = "no_such_kind" + + def summary(self): + return "odd summary text" + + Odd().show(mode="standard") + assert "odd summary text" in capsys.readouterr().out + + def test_unregistered_kind_without_summary_uses_repr(self, capsys): + class Odd(HtmlReprMixin): + _render_kind = "no_such_kind" + + Odd().show(mode="standard") + assert "Odd" in capsys.readouterr().out + + def test_bad_mode_falls_back_not_raises(self, capsys): + make_report().show(mode="not-a-mode") + out = capsys.readouterr().out + assert "freshdata clean report" in out # legacy summary() text + + +class TestTopLevelExports: + def test_set_display_exported(self): + assert fd.set_display is not None + assert "set_display" in fd.__all__ + assert "get_display" in fd.__all__ + + def test_display_options_do_not_touch_cleaning(self): + fd.set_display(mode="silent") + df = pd.DataFrame({"a": [1, 1, None]}) + cleaned, report = fd.clean(df, return_report=True) + assert report.rows_before == 3 # cleaning unaffected by display config From 32a5bad00c4cef873deee179d6b41f360255bff0 Mon Sep 17 00:00:00 2001 From: Johnny Wilson Dougherty Date: Sun, 12 Jul 2026 12:49:26 +0530 Subject: [PATCH 05/12] peel L5: styled terminal renderer with optional rich render/terminal.py renders PeelView as a rich panel (severity-styled attention table, metric chips, verbose/debug section tables); rich is a new optional extra and everything falls back to the plain reference renderer when it is missing or for text-first modes. show(renderer= "terminal") routes through it; content parity with plain is tested. Co-Authored-By: Claude Fable 5 --- pyproject.toml | 4 + src/freshdata/render/mixins.py | 13 +-- src/freshdata/render/terminal.py | 142 +++++++++++++++++++++++++++++++ tests/test_peel_terminal.py | 95 +++++++++++++++++++++ 4 files changed, 249 insertions(+), 5 deletions(-) create mode 100644 src/freshdata/render/terminal.py create mode 100644 tests/test_peel_terminal.py diff --git a/pyproject.toml b/pyproject.toml index 9c59650..6a53769 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -91,6 +91,10 @@ outofcore = [ cli = [ "pyyaml>=5.1", ] +# Styled terminal output (Peel). Everything renders as plain text without it. +rich = [ + "rich>=13", +] # Interactive output layer. The renderers produce self-contained HTML with zero # of these installed; these libraries *upgrade* tables/charts when present and # are never imported by `import freshdata`. diff --git a/src/freshdata/render/mixins.py b/src/freshdata/render/mixins.py index a43925f..6b282f9 100644 --- a/src/freshdata/render/mixins.py +++ b/src/freshdata/render/mixins.py @@ -44,7 +44,7 @@ def show(self, mode: str | None = None, *, renderer: str | None = None) -> Any: the object's ``summary()``/``repr``. """ if mode is not None or renderer == "terminal": - text = self._peel_text(mode) + text = self._peel_text(mode, styled=renderer == "terminal") if text: print(text) return None @@ -72,14 +72,17 @@ def show(self, mode: str | None = None, *, renderer: str | None = None) -> Any: print(f"freshdata: wrote {kind} report to {path}") return path - def _peel_text(self, mode: str | None) -> str: - """Peel plain-text rendering with the never-raise fallback chain.""" + def _peel_text(self, mode: str | None, *, styled: bool = False) -> str: + """Peel text rendering with the never-raise fallback chain.""" try: - from . import normalize, plain + from . import normalize, plain, terminal from .options import get_display options = get_display(mode=mode) if mode is not None else get_display() - return plain.render_plain(normalize.normalize(self), options) + view = normalize.normalize(self) + if styled: + return terminal.render_terminal_text(view, options).rstrip("\n") + return plain.render_plain(view, options) except Exception: summary = getattr(self, "summary", None) if callable(summary): diff --git a/src/freshdata/render/terminal.py b/src/freshdata/render/terminal.py new file mode 100644 index 0000000..6963fe9 --- /dev/null +++ b/src/freshdata/render/terminal.py @@ -0,0 +1,142 @@ +"""Styled terminal renderer over :class:`PeelView`, using ``rich`` when present. + +``rich`` is an optional extra (``pip install freshdata-cleaner[rich]``). Without it — +or for the text-first modes (``compact``/``json``/``plain``/``silent``) — this +module delegates to the plain renderer, which is the reference for content: +the styled output may only differ in styling, never in information. +""" + +from __future__ import annotations + +from typing import Any + +from .options import RenderOptions, get_display +from .plain import render_plain +from .view import PeelView + +_SEVERITY_STYLES = { + "error": "bold red", + "warning": "yellow", + "review": "cyan", + "info": "dim", +} + +_TITLES_STYLE = "bold" + + +def render_terminal_text(view: PeelView, options: RenderOptions | None = None) -> str: + """Render *view* for a terminal, returning the final text (ANSI included + only when color is enabled). Falls back to plain text when ``rich`` is not + installed.""" + options = options or get_display() + mode = options.resolved_mode() + if mode in ("compact", "json", "plain", "silent"): + return render_plain(view, options) + try: + return _render_rich(view, options, mode) + except ImportError: + return render_plain(view, options) + + +def _render_rich(view: PeelView, options: RenderOptions, mode: str) -> str: + from rich.console import Console, Group + from rich.panel import Panel + from rich.table import Table + from rich.text import Text + + color = options.color != "never" + console = Console( + record=True, + width=options.width, + force_terminal=color, + no_color=not color, + highlight=False, + ) + + body: list[Any] = [Text(view.headline)] + if view.metrics: + chips = [] + for metric in view.metrics: + if metric.before is not None: + chips.append(f"{metric.label} {metric.before}→{metric.after}") + else: + chips.append(f"{metric.label} {metric.value}") + body.append(Text(" · ".join(chips), style="dim")) + if view.banner: + body.append(Text(f"!! {view.banner}", style="bold yellow")) + + body.append(Text()) + if view.attention: + table = Table( + title=f"Needs attention ({len(view.attention)})", + title_justify="left", + show_header=False, + box=None, + pad_edge=False, + ) + table.add_column("severity", style="bold") + table.add_column("subject") + table.add_column("text", overflow="fold") + table.add_column("id", style="dim") + for item in view.attention: + table.add_row( + Text(item.severity.capitalize(), style=_SEVERITY_STYLES.get(item.severity, "")), + item.subject, + item.text, + f"[{item.id}]", + ) + body.append(table) + else: + body.append(Text("nothing needs review", style="dim")) + + tail: list[str] = [] + if view.next_step: + tail.append(f"next {view.next_step}") + tail.append('more report.show(mode="verbose") · report.to_json()') + body.append(Text()) + body.append(Text("\n".join(tail), style="dim")) + + if mode in ("verbose", "debug"): + for section in view.sections: + if section.key == "audit" and mode != "debug": + continue + table = Table( + title=f"{section.title} ({section.count})", title_justify="left", box=None + ) + rows = section.rows() + columns: list[str] = [] + for row in rows: + for key in row: + if key not in columns: + columns.append(key) + for key in columns: + table.add_column(key) + for row in rows: + table.add_row(*(_cell(row.get(key)) for key in columns)) + body.append(Text()) + body.append(table) + + console.print( + Panel( + Group(*body), + title=Text(_panel_title(view), style=_TITLES_STYLE), + subtitle=view.status_label, + title_align="left", + subtitle_align="right", + ) + ) + return console.export_text(styles=color) + + +def _panel_title(view: PeelView) -> str: + from .plain import _TITLES + + return _TITLES.get(view.kind, f"freshdata {view.kind}") + + +def _cell(value: object) -> str: + if value in (None, "", [], {}): + return "" + if isinstance(value, float): + return f"{value:g}" + return str(value) diff --git a/tests/test_peel_terminal.py b/tests/test_peel_terminal.py new file mode 100644 index 0000000..289510c --- /dev/null +++ b/tests/test_peel_terminal.py @@ -0,0 +1,95 @@ +"""Layer 5: the styled terminal renderer (rich optional).""" + +from __future__ import annotations + +import builtins + +import pytest + +from freshdata.render.normalize import normalize_clean_report +from freshdata.render.options import get_display, reset_display +from freshdata.render.plain import render_plain +from freshdata.render.terminal import render_terminal_text +from freshdata.report import CleanReport + + +@pytest.fixture(autouse=True) +def _clean_display_state(monkeypatch): + for var in ("FRESHDATA_DISPLAY", "NO_COLOR", "FRESHDATA_NO_PREVIEWS"): + monkeypatch.delenv(var, raising=False) + reset_display() + yield + reset_display() + + +def make_view(**report_kw): + rep = CleanReport( + rows_before=1_000, rows_after=998, cols_before=5, cols_after=5, + missing_before=40, missing_after=0, duration_seconds=0.2, + ) + for key, value in report_kw.items(): + setattr(rep, key, value) + rep.add("missing", "filled 40 value(s) with median", column="age", count=40) + return normalize_clean_report(rep) + + +class TestTerminalRenderer: + def test_standard_contains_all_plain_content(self): + view = make_view(warnings=["column 'x' is mostly empty"]) + out = render_terminal_text(view, get_display(mode="standard", color="never")) + assert "freshdata clean" in out + assert "CHANGED" in out and "REVIEW" in out + assert "998 of 1,000 rows kept" in out + assert "column 'x' is mostly empty" in out + assert "[W1]" in out + assert "nothing needs review" not in out + + def test_no_color_has_no_ansi(self): + out = render_terminal_text( + make_view(warnings=["w"]), get_display(mode="standard", color="never") + ) + assert "\x1b[" not in out + + def test_color_emits_ansi(self): + out = render_terminal_text( + make_view(warnings=["w"]), get_display(mode="standard", color="always") + ) + assert "\x1b[" in out + + def test_verbose_includes_sections(self): + out = render_terminal_text(make_view(), get_display(mode="verbose", color="never")) + assert "Column changes" in out + assert "All actions" in out + + def test_compact_mode_delegates_to_plain(self): + view = make_view() + opts = get_display(mode="compact") + assert render_terminal_text(view, opts) == render_plain(view, opts) + + def test_missing_rich_falls_back_to_plain(self, monkeypatch): + real_import = builtins.__import__ + + def no_rich(name, *args, **kwargs): + if name.startswith("rich"): + raise ImportError("rich unavailable") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", no_rich) + view = make_view(warnings=["w"]) + opts = get_display(mode="standard", color="never") + assert render_terminal_text(view, opts) == render_plain(view, opts) + + def test_width_is_respected(self): + out = render_terminal_text( + make_view(), get_display(mode="standard", width=60, color="never") + ) + assert out.splitlines() + assert all(len(line) <= 60 for line in out.splitlines()) + + def test_show_renderer_terminal_prints(self, capsys): + rep = CleanReport(rows_before=10, rows_after=10, cols_before=2, cols_after=2) + rep.add_warning("check column 'a'") + rep.show(mode="standard", renderer="terminal") + out = capsys.readouterr().out + assert "freshdata clean" in out + assert "check column 'a'" in out From d637c675466b57191e13aa47092f83f49869c7a2 Mon Sep 17 00:00:00 2001 From: Johnny Wilson Dougherty Date: Sun, 12 Jul 2026 16:03:54 +0530 Subject: [PATCH 06/12] peel L6: Peel notebook HTML renderer (opt-in) render/notebook.py builds the Peel card from PeelView over the existing escaped html.py primitives: status chips, metric strip, ranked attention list, next-step code, native
inspect sections (capped at 50 rows with an honest 'showing N of M' line), and the JSON export control. A new RenderOptions.style plus fd.set_display("peel") / FRESHDATA_DISPLAY=peel opt in; legacy layout stays default and FRESHDATA_LEGACY_DISPLAY=1 forces it. Kinds without a normalizer fall back to legacy. All user values escaped. Co-Authored-By: Claude Fable 5 --- src/freshdata/render/mixins.py | 19 +++- src/freshdata/render/notebook.py | 153 +++++++++++++++++++++++++++++++ src/freshdata/render/options.py | 27 ++++-- tests/test_peel_notebook.py | 126 +++++++++++++++++++++++++ 4 files changed, 316 insertions(+), 9 deletions(-) create mode 100644 src/freshdata/render/notebook.py create mode 100644 tests/test_peel_notebook.py diff --git a/src/freshdata/render/mixins.py b/src/freshdata/render/mixins.py index 6b282f9..8286b56 100644 --- a/src/freshdata/render/mixins.py +++ b/src/freshdata/render/mixins.py @@ -18,7 +18,24 @@ class HtmlReprMixin: _render_kind: str = "" def to_html(self) -> str: - """Return a self-contained HTML fragment for this report object.""" + """Return a self-contained HTML fragment for this report object. + + With ``fd.set_display("peel")`` (or ``FRESHDATA_DISPLAY=peel``) objects + that have a Peel normalizer render the Peel card instead of the legacy + layout; ``FRESHDATA_LEGACY_DISPLAY=1`` forces legacy regardless. + """ + import os + + if not os.environ.get("FRESHDATA_LEGACY_DISPLAY"): + from .options import get_display + + if get_display().style == "peel": + try: + from . import normalize, notebook + + return notebook.render_notebook(normalize.normalize(self)) + except KeyError: + pass # no Peel normalizer for this kind yet → legacy from . import renderers return renderers.render(self, self._render_kind) diff --git a/src/freshdata/render/notebook.py b/src/freshdata/render/notebook.py new file mode 100644 index 0000000..0003895 --- /dev/null +++ b/src/freshdata/render/notebook.py @@ -0,0 +1,153 @@ +"""Peel notebook renderer — PeelView → self-contained HTML (spec §10). + +Opt-in for now: ``fd.set_display("peel")`` (or ``FRESHDATA_DISPLAY=peel``) +switches ``_repr_html_``/``to_html`` to this renderer for objects that have a +Peel normalizer; everything else keeps the legacy layout. Disclosure uses +native ``
`` so static exports and no-JS viewers keep full access. + +Everything user-provided is escaped via :func:`freshdata.render.html.esc`; +values never land in HTML attributes. +""" + +from __future__ import annotations + +import json + +from . import html as H +from .options import RenderOptions, get_display +from .view import AttentionItem, PeelView, Section + +#: severity → chip color (labels always accompany color; see spec §14.1). +_SEVERITY_COLORS = { + "error": "#cf222e", + "warning": "#9a6700", + "review": "#0969da", + "info": "#57606a", +} + +_STATUS_COLORS = { + "CLEAN": "#1a7f37", + "CHANGED": "#0969da", + "REVIEW": "#9a6700", + "BLOCKED": "#cf222e", + "PARTIAL": "#9a6700", + "SKIPPED": "#57606a", + "FAILED": "#cf222e", +} + +#: Cap on rows rendered per inspect section; the full data stays reachable +#: through to_dict()/to_json() and verbose text modes (spec §16). +_MAX_SECTION_ROWS = 50 + + +def _status_chips(view: PeelView) -> str: + chips = "".join( + H.badge(status, _STATUS_COLORS.get(status, "#57606a")) for status in view.status + ) + return f'
{chips}
' + + +def _metric_strip(view: PeelView) -> str: + if not view.metrics: + return "" + items = [] + for metric in view.metrics: + if metric.before is not None: + items.append((metric.label, f"{metric.before} → {metric.after}")) + else: + items.append((metric.label, metric.value)) + return H.scorecards(items) + + +def _attention_html(items: tuple[AttentionItem, ...]) -> str: + if not items: + return '

nothing needs review

' + rows = [] + for item in items: + chip = H.badge(item.severity.capitalize(), _SEVERITY_COLORS.get(item.severity, "#57606a")) + subject = f"{H.esc(item.subject)} " if item.subject else "" + rows.append( + f'
  • {chip} {subject}{H.esc(item.text)} ' + f'[{H.esc(item.id)}]
  • ' + ) + return ( + f"

    Needs attention ({len(items)})

    " + f'
      ' + + "".join(rows) + + "
    " + ) + + +def _next_step_html(view: PeelView) -> str: + if not view.next_step: + return "" + return ( + '

    next → ' + f"{H.esc(view.next_step)}

    " + ) + + +def _banner_html(view: PeelView) -> str: + if not view.banner: + return "" + return ( + '

    ' + f"{H.esc(view.banner)}

    " + ) + + +def _section_html(section: Section) -> str: + rows = section.rows() + shown = rows[:_MAX_SECTION_ROWS] + columns: list[str] = [] + for row in shown: + for key in row: + if key not in columns: + columns.append(key) + body = H.table(columns, [[_cell(row.get(key)) for key in columns] for row in shown]) + if len(rows) > len(shown): + body += ( + f'

    showing {len(shown)} of {len(rows)} — ' + "use report.to_json() or " + 'report.show(mode="verbose") for all

    ' + ) + summary = f"{H.esc(section.title)} ({section.count})" + return H.collapsible(summary, body) + + +def _cell(value: object) -> str: + if value in (None, "", [], {}): + return "" + if isinstance(value, float): + return f"{value:g}" + if isinstance(value, (dict, list)): + return json.dumps(value, default=str) + return str(value) + + +def _export_html(view: PeelView) -> str: + report = view.audit_ref + if report is None or not hasattr(report, "to_dict"): + return "" + try: + return H.json_download(f"freshdata_{view.kind}.json", report.to_dict()) + except Exception: # pragma: no cover - export must never break display + return "" + + +def render_notebook(view: PeelView, options: RenderOptions | None = None) -> str: + """Render *view* as a self-contained Peel HTML fragment.""" + options = options or get_display() + parts = [ + _status_chips(view), + _banner_html(view), + _metric_strip(view), + _attention_html(view.attention), + _next_step_html(view), + ] + parts.extend(_section_html(section) for section in view.sections) + parts.append(_export_html(view)) + from .plain import _TITLES + + title = _TITLES.get(view.kind, f"freshdata {view.kind}") + return H.document(title, *parts, subtitle=view.headline) diff --git a/src/freshdata/render/options.py b/src/freshdata/render/options.py index 3aed667..bf1c0a2 100644 --- a/src/freshdata/render/options.py +++ b/src/freshdata/render/options.py @@ -15,6 +15,9 @@ #: Valid display modes (spec §11.1). MODES = ("auto", "compact", "standard", "verbose", "debug", "json", "plain", "silent") +#: Valid notebook-HTML styles: the legacy layout or the Peel card (spec §17). +STYLES = ("legacy", "peel") + @dataclass(frozen=True) class RenderOptions: @@ -25,6 +28,7 @@ class RenderOptions: width: int = 74 previews: bool = True # False → schema-only display, values never shown ascii_icons: bool = False + style: str = "legacy" # notebook HTML style; "peel" opts into the new card def resolved_mode(self, *, isatty: bool | None = None) -> str: """Collapse ``auto`` to a concrete mode for the current environment.""" @@ -38,16 +42,21 @@ def resolved_mode(self, *, isatty: bool | None = None) -> str: _state = {"options": RenderOptions()} -def set_display(**changes: object) -> RenderOptions: - """Set process-wide display preferences, e.g. ``fd.set_display(mode="compact")``. +def set_display(style: str | None = None, **changes: object) -> RenderOptions: + """Set process-wide display preferences. - Returns the new options. Unknown fields raise ``TypeError``; an unknown - mode raises ``ValueError`` so typos fail at configuration time, not render - time. + ``fd.set_display("peel")`` opts into the Peel notebook style; + ``fd.set_display(mode="compact")`` etc. set individual fields. Returns the + new options. Unknown fields raise ``TypeError``; an unknown mode or style + raises ``ValueError`` so typos fail at configuration time, not render time. """ + if style is not None: + changes["style"] = style new = replace(_state["options"], **changes) # type: ignore[arg-type] if new.mode not in MODES: raise ValueError(f"unknown display mode {new.mode!r}; expected one of {MODES}") + if new.style not in STYLES: + raise ValueError(f"unknown display style {new.style!r}; expected one of {STYLES}") _state["options"] = new return new @@ -55,9 +64,11 @@ def set_display(**changes: object) -> RenderOptions: def get_display(**overrides: object) -> RenderOptions: """The effective options: defaults ← env ← :func:`set_display` ← *overrides*.""" opts = _state["options"] - env_mode = os.environ.get("FRESHDATA_DISPLAY") - if env_mode and opts.mode == "auto" and env_mode in MODES: - opts = replace(opts, mode=env_mode) + env_display = os.environ.get("FRESHDATA_DISPLAY") + if env_display == "peel" and opts.style == "legacy": + opts = replace(opts, style="peel") + elif env_display and opts.mode == "auto" and env_display in MODES: + opts = replace(opts, mode=env_display) if os.environ.get("NO_COLOR"): opts = replace(opts, color="never") if os.environ.get("FRESHDATA_NO_PREVIEWS"): diff --git a/tests/test_peel_notebook.py b/tests/test_peel_notebook.py new file mode 100644 index 0000000..5607c8a --- /dev/null +++ b/tests/test_peel_notebook.py @@ -0,0 +1,126 @@ +"""Layer 6: the Peel notebook HTML renderer and its opt-in gate.""" + +from __future__ import annotations + +import pytest + +import freshdata as fd +from freshdata.render.normalize import normalize_clean_report +from freshdata.render.notebook import render_notebook +from freshdata.render.options import reset_display +from freshdata.report import CleanReport + + +@pytest.fixture(autouse=True) +def _clean_display_state(monkeypatch): + for var in ( + "FRESHDATA_DISPLAY", + "FRESHDATA_LEGACY_DISPLAY", + "NO_COLOR", + "FRESHDATA_NO_PREVIEWS", + ): + monkeypatch.delenv(var, raising=False) + reset_display() + yield + reset_display() + + +def make_report(**kw) -> CleanReport: + rep = CleanReport( + rows_before=1_000, rows_after=998, cols_before=5, cols_after=5, + missing_before=40, missing_after=0, duration_seconds=0.2, + ) + for key, value in kw.items(): + setattr(rep, key, value) + rep.add("missing", "filled 40 value(s) with median", column="age", count=40) + return rep + + +def make_view(**kw): + return normalize_clean_report(make_report(**kw)) + + +class TestPeelHtml: + def test_contains_status_headline_attention(self): + html = render_notebook(make_view(warnings=["column 'x' is mostly empty"])) + assert "freshdata clean" in html + assert "CHANGED" in html and "REVIEW" in html + assert "998 of 1,000 rows kept" in html + assert "Needs attention (1)" in html + assert "column 'x' is mostly empty" in html or "column 'x'" in html + assert "[W1]" in html + + def test_empty_attention_is_explicit(self): + assert "nothing needs review" in render_notebook(make_view()) + + def test_sections_are_collapsed_details(self): + html = render_notebook(make_view()) + assert "
    " in html + assert "Column changes" in html + assert "All actions" in html + # collapsed by default: no open attribute + assert "
    " not in html + + def test_banner_present_for_partial(self): + html = render_notebook(make_view(materialized=False)) + assert "PARTIAL" in html + assert "kept in the engine" in html + + def test_next_step_rendered_as_code(self): + html = render_notebook(make_view(warnings=["w"])) + assert "" in html and "explain_clean" in html + + def test_json_export_control_present(self): + assert "JSON" in render_notebook(make_view()) + + def test_hostile_column_name_is_escaped(self): + rep = make_report() + rep.add_warning(' in column ""') + html = render_notebook(normalize_clean_report(rep)) + assert "