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
equiframe/eda/_paths.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Inspect lexical EDA paths before resolving away links and Windows junctions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import stat
|
|
6
|
+
from pathlib import Path, PurePosixPath
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def ordinary_path(path: Path, *, directory: bool = False, allow_missing: bool = False) -> Path:
|
|
10
|
+
lexical = path.absolute()
|
|
11
|
+
for ancestor in reversed(lexical.parents):
|
|
12
|
+
try:
|
|
13
|
+
info = ancestor.lstat()
|
|
14
|
+
except FileNotFoundError:
|
|
15
|
+
if allow_missing:
|
|
16
|
+
continue
|
|
17
|
+
raise
|
|
18
|
+
if not stat.S_ISDIR(info.st_mode) or getattr(info, "st_file_attributes", 0) & 0x400:
|
|
19
|
+
raise ValueError("EDA directories must not be symlinks or reparse points")
|
|
20
|
+
try:
|
|
21
|
+
info = lexical.lstat()
|
|
22
|
+
except FileNotFoundError:
|
|
23
|
+
if not allow_missing:
|
|
24
|
+
raise
|
|
25
|
+
else:
|
|
26
|
+
expected = stat.S_ISDIR(info.st_mode) if directory else stat.S_ISREG(info.st_mode)
|
|
27
|
+
if not expected or getattr(info, "st_file_attributes", 0) & 0x400:
|
|
28
|
+
raise ValueError("EDA paths must be ordinary files/directories without links")
|
|
29
|
+
return lexical.resolve(strict=not allow_missing)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def manifest_member(root: Path, name: object) -> Path:
|
|
33
|
+
if not isinstance(name, str) or not 0 < len(name) <= 4096:
|
|
34
|
+
raise ValueError("Invalid source inventory path")
|
|
35
|
+
relative = PurePosixPath(name)
|
|
36
|
+
if (
|
|
37
|
+
relative.is_absolute()
|
|
38
|
+
or relative.as_posix() != name
|
|
39
|
+
or name == "."
|
|
40
|
+
or ".." in relative.parts
|
|
41
|
+
or "\\" in name
|
|
42
|
+
or ":" in name
|
|
43
|
+
or "\x00" in name
|
|
44
|
+
):
|
|
45
|
+
raise ValueError("Source inventory paths must stay within their delivery")
|
|
46
|
+
member = ordinary_path(root / relative)
|
|
47
|
+
if not member.is_relative_to(root):
|
|
48
|
+
raise ValueError("Source inventory member escaped its delivery")
|
|
49
|
+
return member
|
equiframe/eda/_report.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""Portable local HTML. Values are escaped; plots embed JS, never a remote CDN."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html
|
|
6
|
+
import importlib
|
|
7
|
+
import json
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, cast
|
|
12
|
+
|
|
13
|
+
from equiframe.evidence import publish_json
|
|
14
|
+
|
|
15
|
+
from .models import EdaConfig, EdaManifest, EdaResult, ResultFile
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _table(headers: list[str], rows: list[list[Any]]) -> str:
|
|
19
|
+
return (
|
|
20
|
+
'<div class="scroll"><table><thead><tr>'
|
|
21
|
+
+ "".join("<th>" + html.escape(h) + "</th>" for h in headers)
|
|
22
|
+
+ "</tr></thead><tbody>"
|
|
23
|
+
+ "".join(
|
|
24
|
+
"<tr>" + "".join("<td>" + html.escape(str(cell)) + "</td>" for cell in row) + "</tr>"
|
|
25
|
+
for row in rows
|
|
26
|
+
)
|
|
27
|
+
+ "</tbody></table></div>"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _charts(result: EdaResult, checkpoint: Callable[[], None], max_bytes: int) -> str:
|
|
32
|
+
checkpoint()
|
|
33
|
+
go = importlib.import_module("plotly.graph_objects")
|
|
34
|
+
io = importlib.import_module("plotly.io")
|
|
35
|
+
charts = []
|
|
36
|
+
quality = go.Figure()
|
|
37
|
+
for key, label in (
|
|
38
|
+
("nulls", "NULL"),
|
|
39
|
+
("nan", "NaN"),
|
|
40
|
+
("empty", "Empty string"),
|
|
41
|
+
("zero", "Numeric zero"),
|
|
42
|
+
):
|
|
43
|
+
quality.add_bar(
|
|
44
|
+
name=label,
|
|
45
|
+
x=[html.escape(c.name) for c in result.columns],
|
|
46
|
+
y=[getattr(c, key) for c in result.columns],
|
|
47
|
+
)
|
|
48
|
+
quality.update_layout(
|
|
49
|
+
title="Raw quality counts · categories may overlap",
|
|
50
|
+
barmode="group",
|
|
51
|
+
template="plotly_white",
|
|
52
|
+
)
|
|
53
|
+
charts.append(quality)
|
|
54
|
+
for col in result.columns:
|
|
55
|
+
freq = col.frequencies
|
|
56
|
+
if freq:
|
|
57
|
+
fig = go.Figure(
|
|
58
|
+
go.Bar(x=[html.escape(str(f["value"])) for f in freq], y=[f["count"] for f in freq])
|
|
59
|
+
)
|
|
60
|
+
fig.update_layout(
|
|
61
|
+
title=html.escape(col.name) + " · bounded top categories, full counts",
|
|
62
|
+
template="plotly_white",
|
|
63
|
+
)
|
|
64
|
+
charts.append(fig)
|
|
65
|
+
for document in result.numeric:
|
|
66
|
+
numeric = cast("dict[str, Any]", document)
|
|
67
|
+
for group in numeric.get("groups", []):
|
|
68
|
+
title = html.escape(str(numeric["rule"]["column"]) + " " + str(group["group"]))
|
|
69
|
+
if "histogram" in group:
|
|
70
|
+
hist = group["histogram"]
|
|
71
|
+
fig = go.Figure(go.Bar(x=[h["bin"] for h in hist], y=[h["count"] for h in hist]))
|
|
72
|
+
fig.update_layout(
|
|
73
|
+
title=title + " · full distribution, approximate bin bounds",
|
|
74
|
+
template="plotly_white",
|
|
75
|
+
)
|
|
76
|
+
charts.append(fig)
|
|
77
|
+
sample = group.get("chart_sample", [])
|
|
78
|
+
if sample:
|
|
79
|
+
ys = [float(s["value"]) for s in sample]
|
|
80
|
+
box = go.Figure(go.Box(y=ys, boxpoints="all", name="First finite rows"))
|
|
81
|
+
box.update_layout(
|
|
82
|
+
title=title + " · sampled box; not population quartiles",
|
|
83
|
+
template="plotly_white",
|
|
84
|
+
)
|
|
85
|
+
charts.append(box)
|
|
86
|
+
scatter = go.Figure(go.Scatter(x=[s["row"] for s in sample], y=ys, mode="markers"))
|
|
87
|
+
scatter.update_layout(
|
|
88
|
+
title=title + " · sample by snapshot row, no causal interpretation",
|
|
89
|
+
template="plotly_white",
|
|
90
|
+
)
|
|
91
|
+
charts.append(scatter)
|
|
92
|
+
rendered = []
|
|
93
|
+
size = 0
|
|
94
|
+
for i, fig in enumerate(charts):
|
|
95
|
+
checkpoint()
|
|
96
|
+
block = io.to_html(
|
|
97
|
+
fig,
|
|
98
|
+
full_html=False,
|
|
99
|
+
include_plotlyjs=i == 0,
|
|
100
|
+
div_id=f"eda-chart-{i}",
|
|
101
|
+
config={"responsive": True},
|
|
102
|
+
)
|
|
103
|
+
size += len(block.encode("utf8"))
|
|
104
|
+
if size > max_bytes:
|
|
105
|
+
from .api import _Unavailable
|
|
106
|
+
|
|
107
|
+
raise _Unavailable("report_bytes_budget_exhausted")
|
|
108
|
+
rendered.append(block)
|
|
109
|
+
checkpoint()
|
|
110
|
+
return "".join(rendered)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def publish_report(
|
|
114
|
+
result: EdaResult,
|
|
115
|
+
*,
|
|
116
|
+
config: EdaConfig,
|
|
117
|
+
output: Path,
|
|
118
|
+
checkpoint: Callable[[], None],
|
|
119
|
+
started: float,
|
|
120
|
+
) -> EdaResult:
|
|
121
|
+
from .api import _Cancelled, _sha, _Unavailable
|
|
122
|
+
|
|
123
|
+
chart_html = ""
|
|
124
|
+
chart_note = "Plotly charts not requested. Full calculations and tables remain available."
|
|
125
|
+
if config.charts and result.rows is not None and result.state not in ("failed", "cancelled"):
|
|
126
|
+
try:
|
|
127
|
+
chart_html = _charts(result, checkpoint, config.resources.max_report_bytes)
|
|
128
|
+
chart_note = (
|
|
129
|
+
"Charts embed Plotly locally; no network needed. Samples and approximate "
|
|
130
|
+
"visualization conversions are declared per group."
|
|
131
|
+
)
|
|
132
|
+
import plotly
|
|
133
|
+
|
|
134
|
+
result = result.model_copy(
|
|
135
|
+
update={
|
|
136
|
+
"environment": {**result.environment, "plotly": plotly.__version__},
|
|
137
|
+
"chart_state": "completed",
|
|
138
|
+
}
|
|
139
|
+
)
|
|
140
|
+
except Exception as error:
|
|
141
|
+
from equiframe._error_reporting import report_failure
|
|
142
|
+
|
|
143
|
+
chart_note = "Requested charts unavailable."
|
|
144
|
+
chart_state = (
|
|
145
|
+
"failed" if isinstance(error, (ValueError, TypeError)) else "not_evaluable"
|
|
146
|
+
)
|
|
147
|
+
chart_reason = report_failure(error, "requested_charts_unavailable")
|
|
148
|
+
result = result.model_copy(
|
|
149
|
+
update={"chart_state": chart_state, "chart_reason": chart_reason}
|
|
150
|
+
)
|
|
151
|
+
if isinstance(error, _Cancelled):
|
|
152
|
+
result = result.model_copy(
|
|
153
|
+
update={"state": "cancelled", "reason": "cancellation_requested"}
|
|
154
|
+
)
|
|
155
|
+
if result.state == "completed":
|
|
156
|
+
result = result.model_copy(
|
|
157
|
+
update={
|
|
158
|
+
"state": "not_evaluable",
|
|
159
|
+
"reason": chart_reason,
|
|
160
|
+
}
|
|
161
|
+
)
|
|
162
|
+
elif config.charts:
|
|
163
|
+
chart_note = "Requested charts not evaluated because source/calculation is unavailable."
|
|
164
|
+
result = result.model_copy(
|
|
165
|
+
update={"chart_state": "not_evaluable", "chart_reason": chart_note}
|
|
166
|
+
)
|
|
167
|
+
raw = _table(
|
|
168
|
+
["Column", "Type", "Rows", "NULL", "NaN", "Empty", "Zero", "Distinct present"],
|
|
169
|
+
[
|
|
170
|
+
[c.name, c.physical_type, c.rows, c.nulls, c.nan, c.empty, c.zero, c.distinct_present]
|
|
171
|
+
for c in result.columns
|
|
172
|
+
],
|
|
173
|
+
)
|
|
174
|
+
dup = _table(
|
|
175
|
+
[
|
|
176
|
+
"Columns",
|
|
177
|
+
"Denominator",
|
|
178
|
+
"Incomplete",
|
|
179
|
+
"Repeated groups",
|
|
180
|
+
"Rows involved",
|
|
181
|
+
"Excess",
|
|
182
|
+
"Policy",
|
|
183
|
+
],
|
|
184
|
+
[
|
|
185
|
+
[
|
|
186
|
+
", ".join(d.columns),
|
|
187
|
+
d.denominator,
|
|
188
|
+
d.incomplete_rows,
|
|
189
|
+
d.repeated_groups,
|
|
190
|
+
d.involved_rows,
|
|
191
|
+
d.excess_rows,
|
|
192
|
+
d.policy,
|
|
193
|
+
]
|
|
194
|
+
for d in result.duplicates
|
|
195
|
+
],
|
|
196
|
+
)
|
|
197
|
+
findings = _table(
|
|
198
|
+
["Finding", "Severity", "Column", "Count", "Denominator", "Reason"],
|
|
199
|
+
[[f.code, f.severity, f.column, f.count, f.denominator, f.reason] for f in result.findings],
|
|
200
|
+
)
|
|
201
|
+
details = html.escape(
|
|
202
|
+
json.dumps(
|
|
203
|
+
{
|
|
204
|
+
"conversions": result.conversions,
|
|
205
|
+
"numeric": result.numeric,
|
|
206
|
+
"evidence_links": result.evidence_links,
|
|
207
|
+
},
|
|
208
|
+
ensure_ascii=False,
|
|
209
|
+
indent=2,
|
|
210
|
+
)
|
|
211
|
+
)
|
|
212
|
+
body = f"""<!doctype html><html lang="en"><head><meta charset="utf-8">
|
|
213
|
+
<meta name="viewport" content="width=device-width, initial-scale=1"><title>EquiFrame EDA</title>
|
|
214
|
+
<style>body{{font:16px
|
|
215
|
+
system-ui;color:#193343;background:#eef3f6;margin:0}}main{{max-width:1180px;margin:auto;padding:32px}}
|
|
216
|
+
h1{{font-size:34px;margin:0 0 10px}}.card{{background:white;border:1px solid
|
|
217
|
+
#d5e0e7;border-radius:12px;padding:22px;margin:20px 0}}
|
|
218
|
+
.badge{{padding:6px
|
|
219
|
+
12px;background:#e5edf4;border-radius:6px}}table{{border-collapse:collapse;width:100%}}th,td{{text-align:left;padding:9px;border-bottom:1px
|
|
220
|
+
solid #dde5eb}}
|
|
221
|
+
th{{background:#eef4f7}}.scroll{{overflow:auto}}pre{{white-space:pre-wrap;overflow-wrap:anywhere;font-size:13px}}li{{margin:8px
|
|
222
|
+
0}}
|
|
223
|
+
.meta{{overflow-wrap:anywhere;color:#415969}}.plotly-graph-div{{max-width:100%}}</style></head><body><main>
|
|
224
|
+
<h1>EquiFrame · dataset diagnostics</h1><p class="badge">{html.escape(result.state)}</p>
|
|
225
|
+
<p class="meta">Snapshot: {html.escape(result.snapshot_id)}<br>Source SHA-256:
|
|
226
|
+
{html.escape(str(result.source_sha256))}<br>
|
|
227
|
+
Run: {result.run_id} · Alpha {result.producer_version} · Rows: {result.rows}</p>
|
|
228
|
+
<p>EDA observations are cases to investigate. They establish neither equivalence, independent
|
|
229
|
+
correctness nor authorization to publish a DW.</p>
|
|
230
|
+
<div class="card"><h2>Raw quality</h2><p>Counts and rates use each column's row denominator.
|
|
231
|
+
NULL, NaN, empty string and numeric zero stay distinct.
|
|
232
|
+
Text “0” is text, not a numeric zero. Distinct present excludes NULL/NaN; empty strings remain
|
|
233
|
+
present. Valid excludes NULL/NaN/empty/nonfinite.</p>{raw}</div>
|
|
234
|
+
<div class="card"><h2>Repetitions</h2><p>No rows removed. Group examples are bounded; all groups
|
|
235
|
+
contribute to counts.</p>{dup}</div>
|
|
236
|
+
<div class="card"><h2>Findings and unavailable checks</h2>{findings}</div>
|
|
237
|
+
<div class="card"><h2>Distributions and
|
|
238
|
+
charts</h2><p>{html.escape(chart_note)}</p>{chart_html}</div>
|
|
239
|
+
<div class="card"><h2>Explicit conversion and numeric details</h2><pre>{details}</pre></div>
|
|
240
|
+
<div class="card"><h2>Scope and limits</h2><ul>{
|
|
241
|
+
"".join("<li>" + html.escape(t) + "</li>" for t in result.limits)
|
|
242
|
+
}</ul>
|
|
243
|
+
<p>Rules: {html.escape(config.rules_id)} revision {config.rules_revision}; SHA-256
|
|
244
|
+
{result.config_sha256}.
|
|
245
|
+
Reason: {html.escape(str(result.reason))}. Source unchanged:
|
|
246
|
+
{result.source_unchanged}.</p></div></main></body></html>"""
|
|
247
|
+
try:
|
|
248
|
+
checkpoint()
|
|
249
|
+
if len(body.encode("utf8")) > config.resources.max_report_bytes:
|
|
250
|
+
raise _Unavailable("report_bytes_budget_exhausted")
|
|
251
|
+
except (_Unavailable, _Cancelled) as error:
|
|
252
|
+
result = result.model_copy(
|
|
253
|
+
update={
|
|
254
|
+
"state": "cancelled" if isinstance(error, _Cancelled) else "not_evaluable",
|
|
255
|
+
"reason": str(error),
|
|
256
|
+
"chart_state": "not_evaluable",
|
|
257
|
+
"chart_reason": str(error),
|
|
258
|
+
}
|
|
259
|
+
)
|
|
260
|
+
body = "<!doctype html><p>Report unavailable; see verified summary.json.</p>"
|
|
261
|
+
if len(body.encode("utf8")) > config.resources.max_report_bytes:
|
|
262
|
+
body = "" # No report within budget; explicit unavailable reason is in JSON.
|
|
263
|
+
with (output / "report.html").open("x", encoding="utf8", newline="\n") as stream:
|
|
264
|
+
stream.write(body)
|
|
265
|
+
result = result.model_copy(update={"elapsed_seconds": time.monotonic() - started})
|
|
266
|
+
publish_json(
|
|
267
|
+
{
|
|
268
|
+
"schema_version": "1.0",
|
|
269
|
+
"kind": "equiframe.eda.findings",
|
|
270
|
+
"run_id": result.run_id,
|
|
271
|
+
"items": [f.model_dump(mode="json") for f in result.findings],
|
|
272
|
+
},
|
|
273
|
+
output / "findings.json",
|
|
274
|
+
)
|
|
275
|
+
# Manifest is the commit marker. Rendering/I/O failures before here cannot
|
|
276
|
+
# leave a readable completed run; read_result always requires a closed manifest.
|
|
277
|
+
publish_json(result, output / "summary.json")
|
|
278
|
+
inventory = [
|
|
279
|
+
{"path": p.relative_to(output).as_posix(), "bytes": p.stat().st_size, "sha256": _sha(p)}
|
|
280
|
+
for p in sorted(output.rglob("*"))
|
|
281
|
+
if p.is_file()
|
|
282
|
+
]
|
|
283
|
+
publish_json(
|
|
284
|
+
EdaManifest(
|
|
285
|
+
run_id=result.run_id,
|
|
286
|
+
state=result.state,
|
|
287
|
+
files=tuple(ResultFile.model_validate(f) for f in inventory),
|
|
288
|
+
),
|
|
289
|
+
output / "manifest.json",
|
|
290
|
+
)
|
|
291
|
+
return result
|