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.
Files changed (170) hide show
  1. equiframe/__init__.py +832 -0
  2. equiframe/__main__.py +3 -0
  3. equiframe/_base.py +84 -0
  4. equiframe/_cli_support.py +125 -0
  5. equiframe/_comparison_io.py +161 -0
  6. equiframe/_dataset_provenance.py +146 -0
  7. equiframe/_decimal.py +22 -0
  8. equiframe/_diff_capture.py +433 -0
  9. equiframe/_doctor.py +123 -0
  10. equiframe/_document_io.py +61 -0
  11. equiframe/_documents.py +79 -0
  12. equiframe/_duckdb_transport.py +149 -0
  13. equiframe/_error_reporting.py +52 -0
  14. equiframe/_first_use.py +102 -0
  15. equiframe/_path_compat.py +26 -0
  16. equiframe/_physical.py +133 -0
  17. equiframe/_r_review.R +308 -0
  18. equiframe/_regular_io.py +167 -0
  19. equiframe/_report_format_v1.py +298 -0
  20. equiframe/_report_io.py +591 -0
  21. equiframe/_stability_inventory.py +1421 -0
  22. equiframe/_timezones.py +257 -0
  23. equiframe/_vendor/cyclonedx/LICENSE +201 -0
  24. equiframe/_vendor/cyclonedx/PROVENANCE.md +18 -0
  25. equiframe/_vendor/cyclonedx/bom-1.6.schema.json +5673 -0
  26. equiframe/_vendor/cyclonedx/jsf-0.82.schema.json +240 -0
  27. equiframe/_vendor/cyclonedx/spdx.schema.json +712 -0
  28. equiframe/adapters.py +454 -0
  29. equiframe/assertion_types.py +122 -0
  30. equiframe/assertions.py +271 -0
  31. equiframe/benchmark.py +101 -0
  32. equiframe/bundle.py +240 -0
  33. equiframe/cli.py +693 -0
  34. equiframe/comparison.py +937 -0
  35. equiframe/comparison_report.py +660 -0
  36. equiframe/comparison_types.py +257 -0
  37. equiframe/compatibility.py +546 -0
  38. equiframe/contracts.py +879 -0
  39. equiframe/datasets.py +1301 -0
  40. equiframe/decision.py +34 -0
  41. equiframe/destinations.py +295 -0
  42. equiframe/detector_types.py +431 -0
  43. equiframe/detectors.py +389 -0
  44. equiframe/diff.py +192 -0
  45. equiframe/diff_contracts.py +523 -0
  46. equiframe/docs/adoption.md +329 -0
  47. equiframe/docs/api.md +534 -0
  48. equiframe/docs/arquitecto.md +143 -0
  49. equiframe/docs/choosing-api.md +84 -0
  50. equiframe/docs/cientifico.md +180 -0
  51. equiframe/docs/compatibility.md +254 -0
  52. equiframe/docs/contributing.md +171 -0
  53. equiframe/docs/dependencies.md +237 -0
  54. equiframe/docs/eda.md +166 -0
  55. equiframe/docs/extending.md +158 -0
  56. equiframe/docs/fast-ci.md +52 -0
  57. equiframe/docs/first-use.md +78 -0
  58. equiframe/docs/index.md +142 -0
  59. equiframe/docs/ingestion.md +145 -0
  60. equiframe/docs/inicio.md +129 -0
  61. equiframe/docs/installed-private.md +97 -0
  62. equiframe/docs/journeys.md +362 -0
  63. equiframe/docs/lectura-conversion.md +211 -0
  64. equiframe/docs/licensing.md +17 -0
  65. equiframe/docs/maintenance.md +233 -0
  66. equiframe/docs/migration-ir.md +333 -0
  67. equiframe/docs/option-audit.md +45 -0
  68. equiframe/docs/outcome-gates.md +134 -0
  69. equiframe/docs/performance.md +263 -0
  70. equiframe/docs/piloto-simulado.md +56 -0
  71. equiframe/docs/quickstart.md +156 -0
  72. equiframe/docs/r_review.md +212 -0
  73. equiframe/docs/recipes.md +248 -0
  74. equiframe/docs/releasing.md +183 -0
  75. equiframe/docs/security.md +218 -0
  76. equiframe/docs/semantics.md +274 -0
  77. equiframe/docs/sqlite-snapshots.md +262 -0
  78. equiframe/docs/universal-sources.md +49 -0
  79. equiframe/docs/universalidad-cinco-pasos.md +107 -0
  80. equiframe/docs/upgrading.md +538 -0
  81. equiframe/duckdb_capture.py +410 -0
  82. equiframe/eda/__init__.py +36 -0
  83. equiframe/eda/__main__.py +3 -0
  84. equiframe/eda/_backend.py +612 -0
  85. equiframe/eda/_inventory.py +49 -0
  86. equiframe/eda/_paths.py +49 -0
  87. equiframe/eda/_report.py +291 -0
  88. equiframe/eda/api.py +535 -0
  89. equiframe/eda/cli.py +102 -0
  90. equiframe/eda/models.py +239 -0
  91. equiframe/errors.py +83 -0
  92. equiframe/evidence.py +375 -0
  93. equiframe/examples/integrations/eda_generic.py +60 -0
  94. equiframe/examples/integrations/outcome_gates.py +94 -0
  95. equiframe/examples/integrations/sqlite_snapshot.py +246 -0
  96. equiframe/examples/integrations/sqlite_snapshot_demo.py +226 -0
  97. equiframe/examples/integrations/universal_first_use.py +296 -0
  98. equiframe/examples/journeys/README.md +31 -0
  99. equiframe/examples/journeys/__init__.py +1 -0
  100. equiframe/examples/journeys/a_pandas_refactor.py +88 -0
  101. equiframe/examples/journeys/b_native_migration.py +181 -0
  102. equiframe/examples/journeys/c_retail_etl.py +460 -0
  103. equiframe/examples/journeys/common.py +136 -0
  104. equiframe/examples/journeys/d_financial_reconciliation.py +96 -0
  105. equiframe/examples/journeys/e_telemetry.py +135 -0
  106. equiframe/examples/journeys/f_data_science_preparation.py +84 -0
  107. equiframe/examples/journeys/g_external_snapshots.py +197 -0
  108. equiframe/examples/journeys/h_portable_ci.py +129 -0
  109. equiframe/examples/journeys/requirements.lock +249 -0
  110. equiframe/examples/journeys/verify_journeys.py +117 -0
  111. equiframe/examples/load_convert_compare.py +85 -0
  112. equiframe/execution.py +355 -0
  113. equiframe/execution_types.py +177 -0
  114. equiframe/ingestion.py +744 -0
  115. equiframe/local.py +126 -0
  116. equiframe/migration/__init__.py +94 -0
  117. equiframe/migration/_physical.py +8 -0
  118. equiframe/migration/backends/_timestamp_transport.py +109 -0
  119. equiframe/migration/backends/duckdb.py +651 -0
  120. equiframe/migration/backends/duckdb_decimal.py +125 -0
  121. equiframe/migration/backends/duckdb_time.py +87 -0
  122. equiframe/migration/backends/pandas.py +544 -0
  123. equiframe/migration/backends/polars.py +915 -0
  124. equiframe/migration/backends/polars_decimal.py +231 -0
  125. equiframe/migration/io.py +198 -0
  126. equiframe/migration/ports.py +96 -0
  127. equiframe/migration/runner.py +545 -0
  128. equiframe/migration/runner_types.py +405 -0
  129. equiframe/migration/types.py +354 -0
  130. equiframe/migration/validation.py +619 -0
  131. equiframe/normalization.py +358 -0
  132. equiframe/pipeline.py +274 -0
  133. equiframe/pipeline_types.py +247 -0
  134. equiframe/ports.py +39 -0
  135. equiframe/preprocessing.py +110 -0
  136. equiframe/profile_types.py +343 -0
  137. equiframe/profiles.py +447 -0
  138. equiframe/py.typed +0 -0
  139. equiframe/r_review.py +706 -0
  140. equiframe/reduction.py +518 -0
  141. equiframe/registry.py +121 -0
  142. equiframe/report_rendering.py +366 -0
  143. equiframe/report_types.py +294 -0
  144. equiframe/reporting.py +26 -0
  145. equiframe/rule_types.py +178 -0
  146. equiframe/rules.py +819 -0
  147. equiframe/schemas/r-execution-binding-1.0.schema.json +46 -0
  148. equiframe/schemas/r-review-binding-1.0.schema.json +28 -0
  149. equiframe/schemas/r-review-options-1.0.schema.json +38 -0
  150. equiframe/schemas/r-review-result-1.0.schema.json +207 -0
  151. equiframe/schemas/reviewed-decision-1.0.schema.json +3394 -0
  152. equiframe/serialization.py +152 -0
  153. equiframe/snapshot.py +296 -0
  154. equiframe/sources.py +283 -0
  155. equiframe/tables.py +459 -0
  156. equiframe/types.py +71 -0
  157. equiframe/values.py +252 -0
  158. equiframe/verification_request.py +35 -0
  159. equiframe/verify.py +799 -0
  160. equiframe/verify_types.py +255 -0
  161. equiframe/workflow.py +510 -0
  162. equiframe/workflow_request.py +38 -0
  163. equiframe-0.12.4.dist-info/METADATA +268 -0
  164. equiframe-0.12.4.dist-info/RECORD +170 -0
  165. equiframe-0.12.4.dist-info/WHEEL +4 -0
  166. equiframe-0.12.4.dist-info/entry_points.txt +3 -0
  167. equiframe-0.12.4.dist-info/licenses/LICENSE +21 -0
  168. equiframe-0.12.4.dist-info/licenses/THIRD_PARTY_NOTICES.md +42 -0
  169. equiframe-0.12.4.dist-info/licenses/tools/cyclonedx/LICENSE +201 -0
  170. 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"))