equiframe 0.12.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- equiframe/__init__.py +832 -0
- equiframe/__main__.py +3 -0
- equiframe/_base.py +84 -0
- equiframe/_cli_support.py +125 -0
- equiframe/_comparison_io.py +161 -0
- equiframe/_dataset_provenance.py +146 -0
- equiframe/_decimal.py +22 -0
- equiframe/_diff_capture.py +433 -0
- equiframe/_doctor.py +123 -0
- equiframe/_document_io.py +61 -0
- equiframe/_documents.py +79 -0
- equiframe/_duckdb_transport.py +149 -0
- equiframe/_error_reporting.py +52 -0
- equiframe/_first_use.py +102 -0
- equiframe/_path_compat.py +26 -0
- equiframe/_physical.py +133 -0
- equiframe/_r_review.R +308 -0
- equiframe/_regular_io.py +167 -0
- equiframe/_report_format_v1.py +298 -0
- equiframe/_report_io.py +591 -0
- equiframe/_stability_inventory.py +1421 -0
- equiframe/_timezones.py +257 -0
- equiframe/_vendor/cyclonedx/LICENSE +201 -0
- equiframe/_vendor/cyclonedx/PROVENANCE.md +18 -0
- equiframe/_vendor/cyclonedx/bom-1.6.schema.json +5673 -0
- equiframe/_vendor/cyclonedx/jsf-0.82.schema.json +240 -0
- equiframe/_vendor/cyclonedx/spdx.schema.json +712 -0
- equiframe/adapters.py +454 -0
- equiframe/assertion_types.py +122 -0
- equiframe/assertions.py +271 -0
- equiframe/benchmark.py +101 -0
- equiframe/bundle.py +240 -0
- equiframe/cli.py +693 -0
- equiframe/comparison.py +937 -0
- equiframe/comparison_report.py +660 -0
- equiframe/comparison_types.py +257 -0
- equiframe/compatibility.py +546 -0
- equiframe/contracts.py +879 -0
- equiframe/datasets.py +1301 -0
- equiframe/decision.py +34 -0
- equiframe/destinations.py +295 -0
- equiframe/detector_types.py +431 -0
- equiframe/detectors.py +389 -0
- equiframe/diff.py +192 -0
- equiframe/diff_contracts.py +523 -0
- equiframe/docs/adoption.md +329 -0
- equiframe/docs/api.md +534 -0
- equiframe/docs/arquitecto.md +143 -0
- equiframe/docs/choosing-api.md +84 -0
- equiframe/docs/cientifico.md +180 -0
- equiframe/docs/compatibility.md +254 -0
- equiframe/docs/contributing.md +171 -0
- equiframe/docs/dependencies.md +237 -0
- equiframe/docs/eda.md +166 -0
- equiframe/docs/extending.md +158 -0
- equiframe/docs/fast-ci.md +52 -0
- equiframe/docs/first-use.md +78 -0
- equiframe/docs/index.md +142 -0
- equiframe/docs/ingestion.md +145 -0
- equiframe/docs/inicio.md +129 -0
- equiframe/docs/installed-private.md +97 -0
- equiframe/docs/journeys.md +362 -0
- equiframe/docs/lectura-conversion.md +211 -0
- equiframe/docs/licensing.md +17 -0
- equiframe/docs/maintenance.md +233 -0
- equiframe/docs/migration-ir.md +333 -0
- equiframe/docs/option-audit.md +45 -0
- equiframe/docs/outcome-gates.md +134 -0
- equiframe/docs/performance.md +263 -0
- equiframe/docs/piloto-simulado.md +56 -0
- equiframe/docs/quickstart.md +156 -0
- equiframe/docs/r_review.md +212 -0
- equiframe/docs/recipes.md +248 -0
- equiframe/docs/releasing.md +183 -0
- equiframe/docs/security.md +218 -0
- equiframe/docs/semantics.md +274 -0
- equiframe/docs/sqlite-snapshots.md +262 -0
- equiframe/docs/universal-sources.md +49 -0
- equiframe/docs/universalidad-cinco-pasos.md +107 -0
- equiframe/docs/upgrading.md +538 -0
- equiframe/duckdb_capture.py +410 -0
- equiframe/eda/__init__.py +36 -0
- equiframe/eda/__main__.py +3 -0
- equiframe/eda/_backend.py +612 -0
- equiframe/eda/_inventory.py +49 -0
- equiframe/eda/_paths.py +49 -0
- equiframe/eda/_report.py +291 -0
- equiframe/eda/api.py +535 -0
- equiframe/eda/cli.py +102 -0
- equiframe/eda/models.py +239 -0
- equiframe/errors.py +83 -0
- equiframe/evidence.py +375 -0
- equiframe/examples/integrations/eda_generic.py +60 -0
- equiframe/examples/integrations/outcome_gates.py +94 -0
- equiframe/examples/integrations/sqlite_snapshot.py +246 -0
- equiframe/examples/integrations/sqlite_snapshot_demo.py +226 -0
- equiframe/examples/integrations/universal_first_use.py +296 -0
- equiframe/examples/journeys/README.md +31 -0
- equiframe/examples/journeys/__init__.py +1 -0
- equiframe/examples/journeys/a_pandas_refactor.py +88 -0
- equiframe/examples/journeys/b_native_migration.py +181 -0
- equiframe/examples/journeys/c_retail_etl.py +460 -0
- equiframe/examples/journeys/common.py +136 -0
- equiframe/examples/journeys/d_financial_reconciliation.py +96 -0
- equiframe/examples/journeys/e_telemetry.py +135 -0
- equiframe/examples/journeys/f_data_science_preparation.py +84 -0
- equiframe/examples/journeys/g_external_snapshots.py +197 -0
- equiframe/examples/journeys/h_portable_ci.py +129 -0
- equiframe/examples/journeys/requirements.lock +249 -0
- equiframe/examples/journeys/verify_journeys.py +117 -0
- equiframe/examples/load_convert_compare.py +85 -0
- equiframe/execution.py +355 -0
- equiframe/execution_types.py +177 -0
- equiframe/ingestion.py +744 -0
- equiframe/local.py +126 -0
- equiframe/migration/__init__.py +94 -0
- equiframe/migration/_physical.py +8 -0
- equiframe/migration/backends/_timestamp_transport.py +109 -0
- equiframe/migration/backends/duckdb.py +651 -0
- equiframe/migration/backends/duckdb_decimal.py +125 -0
- equiframe/migration/backends/duckdb_time.py +87 -0
- equiframe/migration/backends/pandas.py +544 -0
- equiframe/migration/backends/polars.py +915 -0
- equiframe/migration/backends/polars_decimal.py +231 -0
- equiframe/migration/io.py +198 -0
- equiframe/migration/ports.py +96 -0
- equiframe/migration/runner.py +545 -0
- equiframe/migration/runner_types.py +405 -0
- equiframe/migration/types.py +354 -0
- equiframe/migration/validation.py +619 -0
- equiframe/normalization.py +358 -0
- equiframe/pipeline.py +274 -0
- equiframe/pipeline_types.py +247 -0
- equiframe/ports.py +39 -0
- equiframe/preprocessing.py +110 -0
- equiframe/profile_types.py +343 -0
- equiframe/profiles.py +447 -0
- equiframe/py.typed +0 -0
- equiframe/r_review.py +706 -0
- equiframe/reduction.py +518 -0
- equiframe/registry.py +121 -0
- equiframe/report_rendering.py +366 -0
- equiframe/report_types.py +294 -0
- equiframe/reporting.py +26 -0
- equiframe/rule_types.py +178 -0
- equiframe/rules.py +819 -0
- equiframe/schemas/r-execution-binding-1.0.schema.json +46 -0
- equiframe/schemas/r-review-binding-1.0.schema.json +28 -0
- equiframe/schemas/r-review-options-1.0.schema.json +38 -0
- equiframe/schemas/r-review-result-1.0.schema.json +207 -0
- equiframe/schemas/reviewed-decision-1.0.schema.json +3394 -0
- equiframe/serialization.py +152 -0
- equiframe/snapshot.py +296 -0
- equiframe/sources.py +283 -0
- equiframe/tables.py +459 -0
- equiframe/types.py +71 -0
- equiframe/values.py +252 -0
- equiframe/verification_request.py +35 -0
- equiframe/verify.py +799 -0
- equiframe/verify_types.py +255 -0
- equiframe/workflow.py +510 -0
- equiframe/workflow_request.py +38 -0
- equiframe-0.12.4.dist-info/METADATA +268 -0
- equiframe-0.12.4.dist-info/RECORD +170 -0
- equiframe-0.12.4.dist-info/WHEEL +4 -0
- equiframe-0.12.4.dist-info/entry_points.txt +3 -0
- equiframe-0.12.4.dist-info/licenses/LICENSE +21 -0
- equiframe-0.12.4.dist-info/licenses/THIRD_PARTY_NOTICES.md +42 -0
- equiframe-0.12.4.dist-info/licenses/tools/cyclonedx/LICENSE +201 -0
- equiframe-0.12.4.dist-info/licenses/tools/cyclonedx/PROVENANCE.md +18 -0
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
"""Pure, bounded renderers for already disclosed comparison facts.
|
|
2
|
+
|
|
3
|
+
No renderer reads an artifact, imports a notebook framework or executes user HTML.
|
|
4
|
+
All three views consume the same sections, including evidence limitations. The
|
|
5
|
+
standalone document adds a restrictive CSP; notebook fragments contain only
|
|
6
|
+
escaped text and locally scoped, static markup/styles.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from collections.abc import Iterator
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from html import escape
|
|
12
|
+
from unicodedata import category
|
|
13
|
+
|
|
14
|
+
from equiframe.errors import ResourceLimitError
|
|
15
|
+
from equiframe.report_types import ComparisonReport, ReportCount, ReportValue, ReportWitness
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class _Section:
|
|
20
|
+
title: str
|
|
21
|
+
headers: tuple[str, ...]
|
|
22
|
+
rows: tuple[tuple[str, ...], ...]
|
|
23
|
+
compact: bool = False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class _Output:
|
|
28
|
+
limit: int
|
|
29
|
+
parts: list[str] = field(default_factory=list)
|
|
30
|
+
size: int = 0
|
|
31
|
+
|
|
32
|
+
def write(self, fragment: str) -> None:
|
|
33
|
+
self.size += len(fragment.encode("utf-8"))
|
|
34
|
+
if self.size > self.limit:
|
|
35
|
+
raise ResourceLimitError("comparison report exceeds max_report_bytes")
|
|
36
|
+
self.parts.append(fragment)
|
|
37
|
+
|
|
38
|
+
def finish(self) -> str:
|
|
39
|
+
return "".join(self.parts)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _safe_text(value: str) -> str:
|
|
43
|
+
"""Make terminal controls, bidi controls and line injection visible as escapes."""
|
|
44
|
+
result: list[str] = []
|
|
45
|
+
short = {"\n": "\\n", "\r": "\\r", "\t": "\\t", "\b": "\\b", "\f": "\\f"}
|
|
46
|
+
for char in value:
|
|
47
|
+
if char in short:
|
|
48
|
+
result.append(short[char])
|
|
49
|
+
elif category(char) in {"Cc", "Cf", "Cs", "Zl", "Zp"}:
|
|
50
|
+
number = ord(char)
|
|
51
|
+
result.append(f"\\u{number:04x}" if number <= 0xFFFF else f"\\U{number:08x}")
|
|
52
|
+
else:
|
|
53
|
+
result.append(char)
|
|
54
|
+
return "".join(result)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _html_text(value: str) -> str:
|
|
58
|
+
return escape(_safe_text(value), quote=True)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _boolean(value: bool) -> str:
|
|
62
|
+
return "true" if value else "false"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _roles(report: ComparisonReport) -> tuple[str, str]:
|
|
66
|
+
if report.reference == "expected":
|
|
67
|
+
return "Candidate (left)", "Expected (right)"
|
|
68
|
+
return "Peer (left)", "Peer (right)"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _count_label(name: str, report: ComparisonReport) -> str:
|
|
72
|
+
candidate, reference = _roles(report)
|
|
73
|
+
labels = {
|
|
74
|
+
"candidate_rows": f"{candidate} rows",
|
|
75
|
+
"reference_rows": f"{reference} rows",
|
|
76
|
+
"candidate_excess_occurrences": f"{candidate} excess occurrences",
|
|
77
|
+
"reference_excess_occurrences": f"{reference} excess occurrences",
|
|
78
|
+
"matched_rows": "Matched rows",
|
|
79
|
+
"difference_records": "Difference records",
|
|
80
|
+
"contract_violations": "Contract violation records",
|
|
81
|
+
"added_rows": "Added rows (left relative to right)",
|
|
82
|
+
"removed_rows": "Removed rows (left relative to right)",
|
|
83
|
+
"modified_rows": "Modified aligned rows",
|
|
84
|
+
}
|
|
85
|
+
return labels.get(name, name)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _count_value(count: ReportCount) -> str:
|
|
89
|
+
if count.precision == "unknown":
|
|
90
|
+
return "unknown"
|
|
91
|
+
if count.precision == "lower_bound":
|
|
92
|
+
return f"at least {count.value}"
|
|
93
|
+
if count.precision == "estimate":
|
|
94
|
+
return f"approximately {count.value}"
|
|
95
|
+
return str(count.value)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _column_labels(witness: ReportWitness, size: int, *, key: bool = False) -> tuple[str, ...]:
|
|
99
|
+
columns = witness.key_columns if key else witness.columns
|
|
100
|
+
if not key and witness.column is not None and size == 1:
|
|
101
|
+
return (witness.column,)
|
|
102
|
+
if len(columns) == size:
|
|
103
|
+
return columns
|
|
104
|
+
return tuple(f"Field {index + 1} (column unavailable)" for index in range(size))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _value_row(side: str, column: str, value: ReportValue) -> tuple[str, ...]:
|
|
108
|
+
return (
|
|
109
|
+
side,
|
|
110
|
+
column,
|
|
111
|
+
value.representation,
|
|
112
|
+
value.disclosure,
|
|
113
|
+
value.kind,
|
|
114
|
+
value.native_type if value.native_type is not None else "unavailable",
|
|
115
|
+
value.physical_type if value.physical_type is not None else "unavailable",
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _witness_sections(report: ComparisonReport) -> Iterator[_Section]:
|
|
120
|
+
candidate, reference = _roles(report)
|
|
121
|
+
for number, witness in enumerate(report.examples, 1):
|
|
122
|
+
yield _Section(
|
|
123
|
+
f"Example {number}: location and reason",
|
|
124
|
+
("Fact", "Value"),
|
|
125
|
+
(
|
|
126
|
+
("Category", witness.category),
|
|
127
|
+
("Code", witness.code),
|
|
128
|
+
("Reason", witness.reason),
|
|
129
|
+
(
|
|
130
|
+
f"{candidate} position (zero-based)",
|
|
131
|
+
str(witness.position) if witness.position is not None else "unavailable",
|
|
132
|
+
),
|
|
133
|
+
(
|
|
134
|
+
f"{reference} position (zero-based)",
|
|
135
|
+
str(witness.other_position)
|
|
136
|
+
if witness.other_position is not None
|
|
137
|
+
else "unavailable",
|
|
138
|
+
),
|
|
139
|
+
("Column", witness.column if witness.column is not None else "not specified"),
|
|
140
|
+
(
|
|
141
|
+
f"{candidate} occurrences",
|
|
142
|
+
str(witness.candidate_count)
|
|
143
|
+
if witness.candidate_count is not None
|
|
144
|
+
else "unavailable",
|
|
145
|
+
),
|
|
146
|
+
(
|
|
147
|
+
f"{reference} occurrences",
|
|
148
|
+
str(witness.reference_count)
|
|
149
|
+
if witness.reference_count is not None
|
|
150
|
+
else "unavailable",
|
|
151
|
+
),
|
|
152
|
+
),
|
|
153
|
+
compact=True,
|
|
154
|
+
)
|
|
155
|
+
rows: list[tuple[str, ...]] = []
|
|
156
|
+
for side, values, key in (
|
|
157
|
+
("Key", witness.key, True),
|
|
158
|
+
(candidate, witness.candidate, False),
|
|
159
|
+
(reference, witness.reference, False),
|
|
160
|
+
):
|
|
161
|
+
labels = _column_labels(witness, len(values), key=key)
|
|
162
|
+
rows.extend(
|
|
163
|
+
_value_row(side, column, value)
|
|
164
|
+
for column, value in zip(labels, values, strict=True)
|
|
165
|
+
)
|
|
166
|
+
if rows:
|
|
167
|
+
yield _Section(
|
|
168
|
+
f"Example {number}: values and types",
|
|
169
|
+
("Side", "Column", "Value", "Disclosure", "Kind", "Native type", "Physical type"),
|
|
170
|
+
tuple(rows),
|
|
171
|
+
compact=True,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _sections(report: ComparisonReport) -> Iterator[_Section]:
|
|
176
|
+
yield _Section(
|
|
177
|
+
"Verdict and coverage",
|
|
178
|
+
("Fact", "Value"),
|
|
179
|
+
(
|
|
180
|
+
("Verdict", report.state),
|
|
181
|
+
("Evaluation complete", _boolean(report.complete)),
|
|
182
|
+
("Scope", report.scope),
|
|
183
|
+
("Reference role", report.reference),
|
|
184
|
+
(
|
|
185
|
+
"Interpretation",
|
|
186
|
+
(
|
|
187
|
+
"Comparison against the declared expected output; "
|
|
188
|
+
"its independence is not established"
|
|
189
|
+
)
|
|
190
|
+
if report.reference == "expected"
|
|
191
|
+
else "Agreement between peers does not establish independent correctness",
|
|
192
|
+
),
|
|
193
|
+
("Strategy", report.strategy),
|
|
194
|
+
("Coverage", report.coverage),
|
|
195
|
+
),
|
|
196
|
+
)
|
|
197
|
+
yield _Section(
|
|
198
|
+
"Reasons",
|
|
199
|
+
("Reason",),
|
|
200
|
+
tuple((reason,) for reason in report.reasons) or (("No recorded reasons",),),
|
|
201
|
+
)
|
|
202
|
+
yield _Section("Next action", ("Action",), ((report.next_action,),))
|
|
203
|
+
yield _Section(
|
|
204
|
+
"Counts",
|
|
205
|
+
("Measure", "Value", "Precision", "Unit"),
|
|
206
|
+
tuple(
|
|
207
|
+
(_count_label(count.name, report), _count_value(count), count.precision, count.unit)
|
|
208
|
+
for count in report.counts
|
|
209
|
+
)
|
|
210
|
+
or (("Counts", "unknown", "unknown", "not specified"),),
|
|
211
|
+
)
|
|
212
|
+
yield _Section(
|
|
213
|
+
"Displayed examples",
|
|
214
|
+
("Fact", "Value"),
|
|
215
|
+
(
|
|
216
|
+
("Included examples", str(report.evidence.included_examples)),
|
|
217
|
+
("Available difference records", str(report.evidence.available_records)),
|
|
218
|
+
("Display complete", _boolean(report.evidence.display_complete)),
|
|
219
|
+
("Counting scope", "Difference records need not be distinct rows"),
|
|
220
|
+
),
|
|
221
|
+
)
|
|
222
|
+
yield from _witness_sections(report)
|
|
223
|
+
yield _Section(
|
|
224
|
+
"Policies",
|
|
225
|
+
("Policy", "Value"),
|
|
226
|
+
tuple((policy.name, policy.value) for policy in report.policies)
|
|
227
|
+
or (("Policy entries", "none recorded"),),
|
|
228
|
+
)
|
|
229
|
+
evidence = report.evidence
|
|
230
|
+
yield _Section(
|
|
231
|
+
"Evidence and limitations",
|
|
232
|
+
("Fact", "Value"),
|
|
233
|
+
(
|
|
234
|
+
("Evidence schema version", evidence.schema_version),
|
|
235
|
+
("Evidence mode", evidence.mode),
|
|
236
|
+
("Inspection", evidence.inspection),
|
|
237
|
+
("Evaluation complete", _boolean(evidence.evaluation_complete)),
|
|
238
|
+
("Counts final", _boolean(evidence.counts_final)),
|
|
239
|
+
("Values withheld", _boolean(evidence.values_withheld)),
|
|
240
|
+
("Metadata withheld", _boolean(evidence.metadata_withheld)),
|
|
241
|
+
("Original evidence retention", evidence.raw_retention),
|
|
242
|
+
("Report retention", evidence.report_retention),
|
|
243
|
+
("Access", evidence.access),
|
|
244
|
+
("Exhaustive evidence required", _boolean(evidence.required_exhaustive)),
|
|
245
|
+
("Evidence requirements", evidence.acceptance),
|
|
246
|
+
("Reproduction", evidence.reproduction),
|
|
247
|
+
("Reproduction scope", evidence.reproduction_scope),
|
|
248
|
+
),
|
|
249
|
+
compact=True,
|
|
250
|
+
)
|
|
251
|
+
if evidence.unmet_requirements:
|
|
252
|
+
yield _Section(
|
|
253
|
+
"Unmet evidence requirements",
|
|
254
|
+
("Requirement",),
|
|
255
|
+
tuple((requirement,) for requirement in evidence.unmet_requirements),
|
|
256
|
+
compact=True,
|
|
257
|
+
)
|
|
258
|
+
options = report.options
|
|
259
|
+
yield _Section(
|
|
260
|
+
"Report disclosure and limits",
|
|
261
|
+
("Setting", "Value"),
|
|
262
|
+
(
|
|
263
|
+
("Mode", options.mode),
|
|
264
|
+
("Include values", _boolean(options.include_values)),
|
|
265
|
+
("Include metadata", _boolean(options.include_metadata)),
|
|
266
|
+
("Redacted columns", str(options.redacted_column_count)),
|
|
267
|
+
("Maximum examples", str(options.max_examples)),
|
|
268
|
+
("Maximum value characters", str(options.max_value_chars)),
|
|
269
|
+
("Maximum report bytes", str(options.max_report_bytes)),
|
|
270
|
+
("Require exhaustive evidence", _boolean(options.require_exhaustive)),
|
|
271
|
+
),
|
|
272
|
+
compact=True,
|
|
273
|
+
)
|
|
274
|
+
yield _Section(
|
|
275
|
+
"Identity",
|
|
276
|
+
("Fact", "Value"),
|
|
277
|
+
(
|
|
278
|
+
("Report schema version", report.schema_version),
|
|
279
|
+
("Contract SHA-256", report.contract_sha256),
|
|
280
|
+
("Comparison specification SHA-256", report.spec_sha256),
|
|
281
|
+
),
|
|
282
|
+
compact=True,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
_STYLE = (
|
|
287
|
+
".equiframe-report{font:14px system-ui,sans-serif;color:#172b3a;"
|
|
288
|
+
"background:#fff;max-width:1100px;padding:1rem;overflow-wrap:anywhere}"
|
|
289
|
+
".equiframe-report h1{font-size:1.5rem}.equiframe-report h2{font-size:1.1rem}"
|
|
290
|
+
".equiframe-report table{width:100%;border-collapse:collapse;margin:.6rem 0 1rem}"
|
|
291
|
+
".equiframe-report .equiframe-facts{table-layout:fixed}"
|
|
292
|
+
".equiframe-report .equiframe-facts th:first-child{width:25%}"
|
|
293
|
+
".equiframe-report th,.equiframe-report td{padding:.4rem;border:1px solid #ccd5df;"
|
|
294
|
+
"text-align:left;vertical-align:top;white-space:pre-wrap}"
|
|
295
|
+
".equiframe-report thead{background:#f0f4f7}.equiframe-report summary{"
|
|
296
|
+
"cursor:pointer;font-weight:bold;margin:.6rem 0}"
|
|
297
|
+
"@media print{.equiframe-report{max-width:none}}"
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _write_html(output: _Output, report: ComparisonReport, *, compact: bool) -> None:
|
|
302
|
+
output.write("<style>" + _STYLE + '</style><section class="equiframe-report">')
|
|
303
|
+
output.write("<h1>EquiFrame comparison</h1>")
|
|
304
|
+
for section in _sections(report):
|
|
305
|
+
collapsed = compact and section.compact
|
|
306
|
+
if collapsed:
|
|
307
|
+
output.write("<details><summary>" + _html_text(section.title) + "</summary>")
|
|
308
|
+
else:
|
|
309
|
+
output.write("<section><h2>" + _html_text(section.title) + "</h2>")
|
|
310
|
+
output.write(
|
|
311
|
+
'<table class="equiframe-facts"><thead><tr>'
|
|
312
|
+
if len(section.headers) == 2
|
|
313
|
+
else "<table><thead><tr>"
|
|
314
|
+
)
|
|
315
|
+
for header in section.headers:
|
|
316
|
+
output.write('<th scope="col">' + _html_text(header) + "</th>")
|
|
317
|
+
output.write("</tr></thead><tbody>")
|
|
318
|
+
for row in section.rows:
|
|
319
|
+
output.write("<tr>")
|
|
320
|
+
for index, cell in enumerate(row):
|
|
321
|
+
if index == 0 and len(section.headers) == 2:
|
|
322
|
+
output.write('<th scope="row">' + _html_text(cell) + "</th>")
|
|
323
|
+
else:
|
|
324
|
+
output.write("<td>" + _html_text(cell) + "</td>")
|
|
325
|
+
output.write("</tr>")
|
|
326
|
+
output.write("</tbody></table>" + ("</details>" if collapsed else "</section>"))
|
|
327
|
+
output.write("</section>")
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def render_comparison_text(report: ComparisonReport) -> str:
|
|
331
|
+
"""Render all disclosed facts as plain text, escaping terminal control characters."""
|
|
332
|
+
report = ComparisonReport.model_validate(report)
|
|
333
|
+
output = _Output(report.options.max_report_bytes)
|
|
334
|
+
output.write("EquiFrame comparison\n")
|
|
335
|
+
for section in _sections(report):
|
|
336
|
+
output.write("\n" + _safe_text(section.title) + "\n")
|
|
337
|
+
if len(section.headers) > 2:
|
|
338
|
+
output.write(" | ".join(_safe_text(header) for header in section.headers) + "\n")
|
|
339
|
+
for row in section.rows:
|
|
340
|
+
separator = ": " if len(row) == 2 else " | "
|
|
341
|
+
output.write(separator.join(_safe_text(cell) for cell in row) + "\n")
|
|
342
|
+
return output.finish()
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def render_comparison_html(report: ComparisonReport) -> str:
|
|
346
|
+
"""Render a standalone inert HTML document, or raise when its UTF-8 budget is exceeded."""
|
|
347
|
+
report = ComparisonReport.model_validate(report)
|
|
348
|
+
output = _Output(report.options.max_report_bytes)
|
|
349
|
+
output.write(
|
|
350
|
+
'<!doctype html><html lang="en"><head><meta charset="utf-8">'
|
|
351
|
+
'<meta http-equiv="Content-Security-Policy" content="default-src \'none\'; '
|
|
352
|
+
"style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'\">"
|
|
353
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">'
|
|
354
|
+
"<title>EquiFrame comparison</title></head><body>"
|
|
355
|
+
)
|
|
356
|
+
_write_html(output, report, compact=False)
|
|
357
|
+
output.write("</body></html>")
|
|
358
|
+
return output.finish()
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def render_comparison_notebook(report: ComparisonReport) -> str:
|
|
362
|
+
"""Render a compact inert fragment without IO, JavaScript or optional dependencies."""
|
|
363
|
+
report = ComparisonReport.model_validate(report)
|
|
364
|
+
output = _Output(report.options.max_report_bytes)
|
|
365
|
+
_write_html(output, report, compact=True)
|
|
366
|
+
return output.finish()
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""Versioned, detached comparison views; disclosure never changes evaluation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated, Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import BeforeValidator, Field, model_validator
|
|
8
|
+
from typing_extensions import Self
|
|
9
|
+
|
|
10
|
+
from equiframe._base import (
|
|
11
|
+
FrozenModel,
|
|
12
|
+
NonNegativeInt,
|
|
13
|
+
PositiveInt,
|
|
14
|
+
_ExplicitOutcome,
|
|
15
|
+
strict_bool_literal,
|
|
16
|
+
)
|
|
17
|
+
from equiframe.comparison_types import ComparisonValue
|
|
18
|
+
from equiframe.pipeline_types import EvidenceFile
|
|
19
|
+
from equiframe.types import ComparisonState, Sha256
|
|
20
|
+
|
|
21
|
+
EvidenceMode = Literal["diagnostic", "detail", "reproduction"]
|
|
22
|
+
CountPrecision = Literal["exact", "lower_bound", "estimate", "unknown"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ReportOptions(FrozenModel):
|
|
26
|
+
"""Separate disclosure/inspection budgets; existing raw capture is unchanged.
|
|
27
|
+
|
|
28
|
+
Values, names and free-form producer text are withheld by default. Reproduction
|
|
29
|
+
exports require both disclosure opt-ins and cannot redact selected columns.
|
|
30
|
+
No setting grants operating-system permissions or deletes original evidence.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
schema_version: Literal["1.0"] = "1.0"
|
|
34
|
+
mode: EvidenceMode = "diagnostic"
|
|
35
|
+
include_values: bool = False
|
|
36
|
+
include_metadata: bool = False
|
|
37
|
+
redact_columns: tuple[str, ...] = ()
|
|
38
|
+
max_examples: Annotated[int, Field(ge=0, le=10000)] = 20
|
|
39
|
+
max_value_chars: Annotated[int, Field(ge=16, le=1048576)] = 4096
|
|
40
|
+
max_report_bytes: PositiveInt = 2 * 1024 * 1024
|
|
41
|
+
max_evidence_bytes: PositiveInt = 64 * 1024 * 1024
|
|
42
|
+
max_records: Annotated[int, Field(gt=0, le=1000000)] = 100000
|
|
43
|
+
require_exhaustive: bool = False
|
|
44
|
+
|
|
45
|
+
@model_validator(mode="after")
|
|
46
|
+
def valid_disclosure(self) -> Self:
|
|
47
|
+
if len(set(self.redact_columns)) != len(self.redact_columns):
|
|
48
|
+
raise ValueError("redacted columns must be unique")
|
|
49
|
+
if self.mode == "reproduction" and (
|
|
50
|
+
not self.include_values or not self.include_metadata or self.redact_columns
|
|
51
|
+
):
|
|
52
|
+
raise ValueError("reproduction requires values and metadata without column redaction")
|
|
53
|
+
return self
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ReportViewPolicy(FrozenModel):
|
|
57
|
+
"""Safe publication policy: column-redaction selectors never leave the builder."""
|
|
58
|
+
|
|
59
|
+
mode: EvidenceMode
|
|
60
|
+
include_values: bool
|
|
61
|
+
include_metadata: bool
|
|
62
|
+
redacted_column_count: NonNegativeInt
|
|
63
|
+
max_examples: NonNegativeInt
|
|
64
|
+
max_value_chars: PositiveInt
|
|
65
|
+
max_report_bytes: PositiveInt
|
|
66
|
+
require_exhaustive: bool
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ReportCount(FrozenModel):
|
|
70
|
+
name: str
|
|
71
|
+
value: NonNegativeInt | None
|
|
72
|
+
precision: CountPrecision
|
|
73
|
+
unit: Literal["rows", "records", "occurrences"]
|
|
74
|
+
|
|
75
|
+
@model_validator(mode="after")
|
|
76
|
+
def known(self) -> Self:
|
|
77
|
+
if (self.value is None) != (self.precision == "unknown"):
|
|
78
|
+
raise ValueError("unknown counts omit their value; known counts require a value")
|
|
79
|
+
return self
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class ReportPolicy(FrozenModel):
|
|
83
|
+
name: str
|
|
84
|
+
value: str
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class ReportValue(FrozenModel):
|
|
88
|
+
disclosure: Literal["visible", "redacted", "omitted"]
|
|
89
|
+
kind: str
|
|
90
|
+
representation: str
|
|
91
|
+
native_type: str | None = None
|
|
92
|
+
physical_type: str | None = None
|
|
93
|
+
value: ComparisonValue | None = None
|
|
94
|
+
|
|
95
|
+
@model_validator(mode="after")
|
|
96
|
+
def visible_only(self) -> Self:
|
|
97
|
+
if (self.disclosure == "visible") != (self.value is not None):
|
|
98
|
+
raise ValueError("only visible values contain scalar evidence")
|
|
99
|
+
if self.disclosure != "visible" and self.representation != f"[{self.disclosure}]":
|
|
100
|
+
raise ValueError("withheld values require a fixed non-disclosing representation")
|
|
101
|
+
return self
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class ReportWitness(FrozenModel):
|
|
105
|
+
category: Literal["contract", "content", "schema"]
|
|
106
|
+
code: str
|
|
107
|
+
reason: str
|
|
108
|
+
position: NonNegativeInt | None
|
|
109
|
+
other_position: NonNegativeInt | None
|
|
110
|
+
column: str | None
|
|
111
|
+
columns: tuple[str, ...]
|
|
112
|
+
key_columns: tuple[str, ...]
|
|
113
|
+
key: tuple[ReportValue, ...]
|
|
114
|
+
candidate: tuple[ReportValue, ...]
|
|
115
|
+
reference: tuple[ReportValue, ...]
|
|
116
|
+
candidate_count: NonNegativeInt | None
|
|
117
|
+
reference_count: NonNegativeInt | None
|
|
118
|
+
|
|
119
|
+
@model_validator(mode="after")
|
|
120
|
+
def occurrence_counts(self) -> Self:
|
|
121
|
+
if self.category != "content" and (
|
|
122
|
+
self.candidate_count is not None or self.reference_count is not None
|
|
123
|
+
):
|
|
124
|
+
raise ValueError("contract/schema records do not calculate occurrence counts")
|
|
125
|
+
if self.category == "content" and (
|
|
126
|
+
self.candidate_count is None or self.reference_count is None
|
|
127
|
+
):
|
|
128
|
+
raise ValueError("content witnesses require recorded occurrence counts")
|
|
129
|
+
return self
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class ReportEvidenceManifest(FrozenModel):
|
|
133
|
+
schema_version: Literal["1.0"] = "1.0"
|
|
134
|
+
mode: EvidenceMode
|
|
135
|
+
inspection: Literal["result_only", "verified_differences"]
|
|
136
|
+
evaluation_complete: bool
|
|
137
|
+
counts_final: bool
|
|
138
|
+
available_records: NonNegativeInt
|
|
139
|
+
included_examples: NonNegativeInt
|
|
140
|
+
display_complete: bool
|
|
141
|
+
values_withheld: bool
|
|
142
|
+
metadata_withheld: bool
|
|
143
|
+
raw_retention: Literal["unchanged_in_original_comparison"] = "unchanged_in_original_comparison"
|
|
144
|
+
report_retention: Literal["until_caller_deletes"] = "until_caller_deletes"
|
|
145
|
+
access: Literal["caller_managed_local_storage_no_encryption"] = (
|
|
146
|
+
"caller_managed_local_storage_no_encryption"
|
|
147
|
+
)
|
|
148
|
+
required_exhaustive: bool
|
|
149
|
+
acceptance: Literal["satisfied", "not_satisfied"]
|
|
150
|
+
unmet_requirements: tuple[str, ...]
|
|
151
|
+
reproduction: Literal[
|
|
152
|
+
"not_requested", "requires_verified_export", "exported_materialized_inputs"
|
|
153
|
+
]
|
|
154
|
+
reproduction_scope: Literal["materialized_comparison_only_no_transform_reexecution"] = (
|
|
155
|
+
"materialized_comparison_only_no_transform_reexecution"
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
@model_validator(mode="after")
|
|
159
|
+
def accepted(self) -> Self:
|
|
160
|
+
if (self.acceptance == "satisfied") != (not self.unmet_requirements):
|
|
161
|
+
raise ValueError("evidence acceptance must describe unmet requirements")
|
|
162
|
+
if self.included_examples > self.available_records:
|
|
163
|
+
raise ValueError("display examples exceed available records")
|
|
164
|
+
if self.display_complete != (self.included_examples == self.available_records):
|
|
165
|
+
raise ValueError("display completeness must agree with its record counts")
|
|
166
|
+
return self
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class ComparisonReport(_ExplicitOutcome, FrozenModel):
|
|
170
|
+
"""One sanitized source for JSON, console, standalone HTML and notebooks.
|
|
171
|
+
|
|
172
|
+
Formatting this object performs no file IO, imports no notebook framework and
|
|
173
|
+
never evaluates a source. JSON is a view, not a serialized ComparisonResult.
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
_truth_value_guidance = (
|
|
177
|
+
"use assert_equivalent on the original ComparisonResult; a generated report is not approval"
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
schema_version: Literal["1.0"] = "1.0"
|
|
181
|
+
state: ComparisonState
|
|
182
|
+
complete: bool
|
|
183
|
+
scope: Literal["output_schema"] = "output_schema"
|
|
184
|
+
reference: Literal["peer", "expected"]
|
|
185
|
+
strategy: Literal["pending", "sequence", "key", "multiset", "matching"]
|
|
186
|
+
coverage: Literal["complete", "partial"]
|
|
187
|
+
contract_sha256: Sha256
|
|
188
|
+
spec_sha256: Sha256
|
|
189
|
+
counts: tuple[ReportCount, ...]
|
|
190
|
+
reasons: tuple[str, ...]
|
|
191
|
+
next_action: str
|
|
192
|
+
policies: tuple[ReportPolicy, ...]
|
|
193
|
+
examples: tuple[ReportWitness, ...]
|
|
194
|
+
evidence: ReportEvidenceManifest
|
|
195
|
+
options: ReportViewPolicy
|
|
196
|
+
|
|
197
|
+
@model_validator(mode="after")
|
|
198
|
+
def consistent(self) -> Self:
|
|
199
|
+
if (self.coverage == "complete") != self.complete:
|
|
200
|
+
raise ValueError("coverage must preserve comparison completeness")
|
|
201
|
+
if self.state in ("equivalent", "different") and not self.complete:
|
|
202
|
+
raise ValueError("an interrupted comparison cannot have a final verdict")
|
|
203
|
+
if self.evidence.evaluation_complete != self.complete:
|
|
204
|
+
raise ValueError("evidence must preserve evaluation completeness")
|
|
205
|
+
if self.evidence.counts_final != self.complete:
|
|
206
|
+
raise ValueError("count completeness must preserve evaluation completeness")
|
|
207
|
+
if self.evidence.mode != self.options.mode:
|
|
208
|
+
raise ValueError("evidence mode must agree with the view policy")
|
|
209
|
+
if self.evidence.required_exhaustive != self.options.require_exhaustive:
|
|
210
|
+
raise ValueError("evidence requirement must agree with the view policy")
|
|
211
|
+
counts = {count.name: count for count in self.counts}
|
|
212
|
+
if set(counts) != {
|
|
213
|
+
"candidate_rows",
|
|
214
|
+
"reference_rows",
|
|
215
|
+
"matched_rows",
|
|
216
|
+
"difference_records",
|
|
217
|
+
"contract_violations",
|
|
218
|
+
"added_rows",
|
|
219
|
+
"removed_rows",
|
|
220
|
+
"modified_rows",
|
|
221
|
+
"candidate_excess_occurrences",
|
|
222
|
+
"reference_excess_occurrences",
|
|
223
|
+
}:
|
|
224
|
+
raise ValueError("report requires the complete set of named counts")
|
|
225
|
+
if counts["difference_records"].value != self.evidence.available_records:
|
|
226
|
+
raise ValueError("record count must agree with the evidence manifest")
|
|
227
|
+
if self.state == "equivalent" and any(
|
|
228
|
+
counts[name].precision != "exact" or counts[name].value is None
|
|
229
|
+
for name in ("candidate_rows", "reference_rows", "matched_rows")
|
|
230
|
+
):
|
|
231
|
+
raise ValueError("equivalent report requires known exact row counts")
|
|
232
|
+
if self.state == "equivalent" and (
|
|
233
|
+
self.evidence.available_records
|
|
234
|
+
or self.examples
|
|
235
|
+
or self.reasons
|
|
236
|
+
or counts["candidate_rows"].value != counts["reference_rows"].value
|
|
237
|
+
or counts["matched_rows"].value != counts["candidate_rows"].value
|
|
238
|
+
):
|
|
239
|
+
raise ValueError("equivalent report requires complete matching without differences")
|
|
240
|
+
if self.state == "different" and not self.evidence.available_records:
|
|
241
|
+
raise ValueError("different report requires recorded differences")
|
|
242
|
+
if not self.complete and any(count.precision == "exact" for count in self.counts):
|
|
243
|
+
raise ValueError("partial evaluation cannot claim final exact counts")
|
|
244
|
+
if self.evidence.included_examples != len(self.examples):
|
|
245
|
+
raise ValueError("manifest must describe the actual displayed examples")
|
|
246
|
+
if len({count.name for count in self.counts}) != len(self.counts):
|
|
247
|
+
raise ValueError("count names must be unique")
|
|
248
|
+
for witness in self.examples:
|
|
249
|
+
if not self.options.include_metadata and any(
|
|
250
|
+
value.native_type is not None or value.physical_type is not None
|
|
251
|
+
for value in (*witness.key, *witness.candidate, *witness.reference)
|
|
252
|
+
):
|
|
253
|
+
raise ValueError("disclosure policy forbids producer type metadata")
|
|
254
|
+
if not self.options.include_values and any(
|
|
255
|
+
value.disclosure == "visible"
|
|
256
|
+
for value in (*witness.key, *witness.candidate, *witness.reference)
|
|
257
|
+
):
|
|
258
|
+
raise ValueError("disclosure policy forbids visible values")
|
|
259
|
+
return self
|
|
260
|
+
|
|
261
|
+
def to_text(self) -> str:
|
|
262
|
+
from equiframe.report_rendering import render_comparison_text
|
|
263
|
+
|
|
264
|
+
return render_comparison_text(self)
|
|
265
|
+
|
|
266
|
+
def to_html(self) -> str:
|
|
267
|
+
from equiframe.report_rendering import render_comparison_html
|
|
268
|
+
|
|
269
|
+
return render_comparison_html(self)
|
|
270
|
+
|
|
271
|
+
def _repr_html_(self) -> str:
|
|
272
|
+
from equiframe.report_rendering import render_comparison_notebook
|
|
273
|
+
|
|
274
|
+
return render_comparison_notebook(self)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class ReportPublicationManifest(FrozenModel):
|
|
278
|
+
"""Last-written success receipt for a closed report export inventory."""
|
|
279
|
+
|
|
280
|
+
schema_version: Literal["1.0"] = "1.0"
|
|
281
|
+
publication_complete: Annotated[Literal[True], BeforeValidator(strict_bool_literal)] = True
|
|
282
|
+
report_evidence: ReportEvidenceManifest
|
|
283
|
+
files: tuple[EvidenceFile, ...]
|
|
284
|
+
|
|
285
|
+
@model_validator(mode="after")
|
|
286
|
+
def unique_files(self) -> Self:
|
|
287
|
+
names = [item.path for item in self.files]
|
|
288
|
+
if len(names) != len(set(names)):
|
|
289
|
+
raise ValueError("report inventory paths must be unique")
|
|
290
|
+
if not {"report.json", "report.html", "report.txt"} <= set(names):
|
|
291
|
+
raise ValueError("report publication requires JSON, HTML and text")
|
|
292
|
+
if "evidence-manifest.json" in names:
|
|
293
|
+
raise ValueError("publication inventory must not include itself")
|
|
294
|
+
return self
|
equiframe/reporting.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Reports expose selected operational facts, never parameter values or exception messages."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from equiframe._document_io import write_document as _write_document
|
|
6
|
+
from equiframe._report_format_v1 import pipeline_html_v1, pipeline_summary_v1
|
|
7
|
+
from equiframe.evidence import publish_json
|
|
8
|
+
from equiframe.pipeline_types import PipelineResult
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def pipeline_summary(result: PipelineResult) -> dict[str, object]:
|
|
12
|
+
"""Current operational summary; persisted format 1.0 is separately frozen."""
|
|
13
|
+
return pipeline_summary_v1(result)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def render_report(summary: dict[str, object]) -> str:
|
|
17
|
+
"""Current presentation; escape values and preserve the existing 1.0 layout."""
|
|
18
|
+
return pipeline_html_v1(summary)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def write_report(result: PipelineResult, directory: Path) -> None:
|
|
22
|
+
"""Publish a new report directory; existing reports are never replaced."""
|
|
23
|
+
summary = pipeline_summary(result)
|
|
24
|
+
directory.mkdir(parents=True, exist_ok=False)
|
|
25
|
+
publish_json(summary, directory / "report.json")
|
|
26
|
+
_write_document(directory / "report.html", render_report(summary).encode("utf-8"))
|