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/__main__.py
ADDED
equiframe/_base.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Shared strict configuration and validation helpers (not public API)."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated, ClassVar
|
|
4
|
+
|
|
5
|
+
from pydantic import (
|
|
6
|
+
AfterValidator,
|
|
7
|
+
BaseModel,
|
|
8
|
+
ConfigDict,
|
|
9
|
+
Field,
|
|
10
|
+
StringConstraints,
|
|
11
|
+
ValidationError,
|
|
12
|
+
)
|
|
13
|
+
from pydantic_core import PydanticCustomError
|
|
14
|
+
from typing_extensions import Never
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class _ExplicitOutcome:
|
|
18
|
+
"""Field-free guard for the explicitly selected operational outcome models.
|
|
19
|
+
|
|
20
|
+
Object presence, technical completion and an approval decision are separate.
|
|
21
|
+
This mixin intentionally does not apply to every frozen model.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
__slots__ = ()
|
|
25
|
+
_truth_value_guidance: ClassVar[str]
|
|
26
|
+
|
|
27
|
+
def __bool__(self) -> Never:
|
|
28
|
+
raise TypeError(f"{type(self).__name__} has no truth value; {self._truth_value_guidance}")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class FrozenModel(BaseModel):
|
|
32
|
+
model_config = ConfigDict(
|
|
33
|
+
strict=True,
|
|
34
|
+
frozen=True,
|
|
35
|
+
extra="forbid",
|
|
36
|
+
validate_default=True,
|
|
37
|
+
revalidate_instances="always",
|
|
38
|
+
allow_inf_nan=False,
|
|
39
|
+
hide_input_in_errors=True,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _text(value: str) -> str:
|
|
44
|
+
if not value.strip() or value != value.strip():
|
|
45
|
+
raise ValueError("text must be nonempty and have no surrounding whitespace")
|
|
46
|
+
if any(0xD800 <= ord(char) <= 0xDFFF for char in value):
|
|
47
|
+
raise ValueError("text must contain valid Unicode scalar values")
|
|
48
|
+
return value
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
Text = Annotated[str, StringConstraints(min_length=1), AfterValidator(_text)]
|
|
52
|
+
Identifier = Annotated[Text, StringConstraints(pattern=r"^[A-Za-z][A-Za-z0-9_.-]*$")]
|
|
53
|
+
PositiveInt = Annotated[int, Field(gt=0)]
|
|
54
|
+
NonNegativeInt = Annotated[int, Field(ge=0)]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def fail(code: str, message: str, *location: str | int) -> Never:
|
|
58
|
+
"""Attach a cross-field failure to its actual field, including nested paths."""
|
|
59
|
+
raise ValidationError.from_exception_data(
|
|
60
|
+
"EquiFrame",
|
|
61
|
+
[{"type": PydanticCustomError(code, message), "loc": location, "input": None}],
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def unique(values: tuple[str, ...], field: str) -> None:
|
|
66
|
+
seen: set[str] = set()
|
|
67
|
+
for index, value in enumerate(values):
|
|
68
|
+
if value in seen:
|
|
69
|
+
fail("duplicate_reference", "repeated reference: " + value, field, index)
|
|
70
|
+
seen.add(value)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def strict_int_literal(value: object) -> int:
|
|
74
|
+
"""Preserve strict integer metadata before Literal's numeric equality lookup."""
|
|
75
|
+
if type(value) is not int:
|
|
76
|
+
raise PydanticCustomError("literal_type", "integer literals require an exact integer type")
|
|
77
|
+
return value
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def strict_bool_literal(value: object) -> bool:
|
|
81
|
+
"""Do not normalize numeric 0/1 to a declared boolean literal."""
|
|
82
|
+
if type(value) is not bool:
|
|
83
|
+
raise PydanticCustomError("literal_type", "boolean literals require an exact boolean type")
|
|
84
|
+
return value
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Fixed-text CLI guidance, separate from semantic decisions and exit-code policy.
|
|
2
|
+
|
|
3
|
+
The caller owns its historical code and status. This helper never formats an
|
|
4
|
+
exception, cause, model error, path or user-supplied value into terminal output.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from pydantic import ValidationError
|
|
11
|
+
from typing_extensions import Never
|
|
12
|
+
|
|
13
|
+
from equiframe.errors import (
|
|
14
|
+
ArtifactIntegrityError,
|
|
15
|
+
ArtifactIOError,
|
|
16
|
+
CapabilityError,
|
|
17
|
+
ContractIOError,
|
|
18
|
+
ContractValidationError,
|
|
19
|
+
ExecutionCancelled,
|
|
20
|
+
ExecutionTimeout,
|
|
21
|
+
InvalidExecutionInput,
|
|
22
|
+
MissingDependencyError,
|
|
23
|
+
NativeEngineError,
|
|
24
|
+
ResourceLimitError,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def failure_hint(error: Exception, *, input_validation: bool = True) -> tuple[str, str]:
|
|
29
|
+
"""Return a bounded category and actionable guidance without inspecting payloads."""
|
|
30
|
+
if isinstance(error, (ArtifactIOError, ContractIOError)) and isinstance(
|
|
31
|
+
error.__cause__,
|
|
32
|
+
(
|
|
33
|
+
FileNotFoundError,
|
|
34
|
+
FileExistsError,
|
|
35
|
+
PermissionError,
|
|
36
|
+
IsADirectoryError,
|
|
37
|
+
NotADirectoryError,
|
|
38
|
+
),
|
|
39
|
+
):
|
|
40
|
+
# Inspect only the immediate OS error class, never its path or message.
|
|
41
|
+
return failure_hint(error.__cause__, input_validation=input_validation)
|
|
42
|
+
if isinstance(error, FileNotFoundError):
|
|
43
|
+
return "missing_path", "Check that the requested input and its parent directory exist."
|
|
44
|
+
if isinstance(error, FileExistsError):
|
|
45
|
+
return "existing_output", "Choose a new output path; existing evidence is not overwritten."
|
|
46
|
+
if isinstance(error, PermissionError):
|
|
47
|
+
return "permission_denied", "Check read access to inputs and write access to the output."
|
|
48
|
+
if isinstance(error, (IsADirectoryError, NotADirectoryError)):
|
|
49
|
+
return "path_kind", "Check which arguments require files and which require directories."
|
|
50
|
+
if isinstance(error, MissingDependencyError):
|
|
51
|
+
return "optional_dependency", "Check the required extra in this interpreter's environment."
|
|
52
|
+
if isinstance(error, ImportError):
|
|
53
|
+
return "module_unavailable", "Check the selected module and its installed dependencies."
|
|
54
|
+
if isinstance(error, ArtifactIntegrityError):
|
|
55
|
+
return "evidence_integrity", "Restore the original evidence or rerun into a new directory."
|
|
56
|
+
if isinstance(error, CapabilityError):
|
|
57
|
+
return "unsupported_capability", "Check the documented engine and representation limits."
|
|
58
|
+
if isinstance(error, ExecutionCancelled):
|
|
59
|
+
return "cancelled", "Keep partial evidence; retry only when the operation is still needed."
|
|
60
|
+
if isinstance(error, ExecutionTimeout):
|
|
61
|
+
return "timeout", "Review the workload and explicit time budget before retrying."
|
|
62
|
+
if isinstance(error, ResourceLimitError):
|
|
63
|
+
return (
|
|
64
|
+
"resource_limit",
|
|
65
|
+
"Review the workload and explicit resource budgets before retrying.",
|
|
66
|
+
)
|
|
67
|
+
if isinstance(error, NativeEngineError):
|
|
68
|
+
return "native_engine", "Inspect the recorded stage and the trusted native transformation."
|
|
69
|
+
if isinstance(error, (OSError, ArtifactIOError, ContractIOError)):
|
|
70
|
+
return "io_failure", "Check input availability, output access and available disk space."
|
|
71
|
+
if isinstance(error, (json.JSONDecodeError, UnicodeDecodeError)):
|
|
72
|
+
return "document_syntax", "Use a valid UTF-8 JSON document for the selected command."
|
|
73
|
+
if isinstance(error, (ValidationError, ContractValidationError)):
|
|
74
|
+
if isinstance(error, ContractValidationError) and any(
|
|
75
|
+
issue.code == "duplicate_json_key" for issue in error.issues
|
|
76
|
+
):
|
|
77
|
+
return (
|
|
78
|
+
"duplicate_json_key",
|
|
79
|
+
"Use one value per JSON member; duplicate keys are refused.",
|
|
80
|
+
)
|
|
81
|
+
return (
|
|
82
|
+
(
|
|
83
|
+
"document_validation",
|
|
84
|
+
"Check the request's required fields, types and schema version.",
|
|
85
|
+
)
|
|
86
|
+
if input_validation
|
|
87
|
+
else (
|
|
88
|
+
"evidence_validation",
|
|
89
|
+
"Check the evidence format and restore its original files.",
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
if isinstance(error, (ValueError, InvalidExecutionInput)):
|
|
93
|
+
return (
|
|
94
|
+
("input_configuration", "Check the command arguments and declared input constraints.")
|
|
95
|
+
if input_validation
|
|
96
|
+
else (
|
|
97
|
+
"evidence_validation",
|
|
98
|
+
"Check the evidence format and restore its original files.",
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
return "operation_failure", "Inspect any retained evidence and prepare a minimal reproduction."
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def write_failure(
|
|
105
|
+
error: Exception,
|
|
106
|
+
*,
|
|
107
|
+
code: str,
|
|
108
|
+
input_validation: bool = True,
|
|
109
|
+
) -> None:
|
|
110
|
+
"""Retain the original first-line code and add one fixed, human-readable hint."""
|
|
111
|
+
category, hint = failure_hint(error, input_validation=input_validation)
|
|
112
|
+
typer.echo(code, err=True)
|
|
113
|
+
typer.echo(f"hint[{category}]: {hint}", err=True)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def exit_with_failure(
|
|
117
|
+
error: Exception,
|
|
118
|
+
*,
|
|
119
|
+
code: str,
|
|
120
|
+
exit_code: int,
|
|
121
|
+
input_validation: bool = True,
|
|
122
|
+
) -> Never:
|
|
123
|
+
"""Write guidance and preserve the caller's existing Typer exit-code policy."""
|
|
124
|
+
write_failure(error, code=code, input_validation=input_validation)
|
|
125
|
+
raise typer.Exit(code=exit_code) from None
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Streaming local snapshots/readers. No read_all/read_table on comparison paths."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from collections.abc import Callable, Iterator
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
from uuid import uuid4
|
|
11
|
+
|
|
12
|
+
from equiframe.errors import ArtifactIntegrityError, CapabilityError, ResourceLimitError
|
|
13
|
+
from equiframe.local import local_path
|
|
14
|
+
from equiframe.tables import ArrowInput, require_dependency
|
|
15
|
+
from equiframe.types import ArtifactRef
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
import pyarrow as pa
|
|
19
|
+
|
|
20
|
+
from equiframe.comparison_types import ComparisonOptions
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def file_ref(
|
|
24
|
+
path: Path, format_name: str, *, check: Callable[[], None] | None = None
|
|
25
|
+
) -> ArtifactRef:
|
|
26
|
+
digest = hashlib.sha256()
|
|
27
|
+
size = 0
|
|
28
|
+
with path.open("rb") as source:
|
|
29
|
+
while block := source.read(1024 * 1024):
|
|
30
|
+
if check:
|
|
31
|
+
check()
|
|
32
|
+
digest.update(block)
|
|
33
|
+
size += len(block)
|
|
34
|
+
return ArtifactRef.model_validate(
|
|
35
|
+
dict(
|
|
36
|
+
schema_version="1.0",
|
|
37
|
+
uri=path.resolve().as_uri(),
|
|
38
|
+
sha256=digest.hexdigest(),
|
|
39
|
+
size_bytes=size,
|
|
40
|
+
format=format_name,
|
|
41
|
+
media_type={
|
|
42
|
+
"arrow": "application/vnd.apache.arrow.file",
|
|
43
|
+
"parquet": "application/vnd.apache.parquet",
|
|
44
|
+
"jsonl": "application/x-ndjson",
|
|
45
|
+
"json": "application/json",
|
|
46
|
+
}[format_name],
|
|
47
|
+
)
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def snapshot(
|
|
52
|
+
value: ArrowInput | ArtifactRef | Path,
|
|
53
|
+
directory: Path,
|
|
54
|
+
options: ComparisonOptions,
|
|
55
|
+
check: Callable[[], None],
|
|
56
|
+
) -> ArtifactRef:
|
|
57
|
+
require_dependency("pyarrow", "arrow")
|
|
58
|
+
import pyarrow as pa
|
|
59
|
+
|
|
60
|
+
if isinstance(value, ArrowInput):
|
|
61
|
+
if not isinstance(value.table, pa.Table):
|
|
62
|
+
raise TypeError("ArrowInput requires an Arrow Table")
|
|
63
|
+
path = directory / (uuid4().hex + ".arrow")
|
|
64
|
+
try:
|
|
65
|
+
with path.open("xb") as sink, pa.ipc.new_file(sink, value.table.schema) as writer:
|
|
66
|
+
for batch in value.table.to_reader(max_chunksize=options.batch_rows):
|
|
67
|
+
check()
|
|
68
|
+
if batch.nbytes > options.max_batch_bytes:
|
|
69
|
+
raise ResourceLimitError("max_batch_bytes")
|
|
70
|
+
batch.validate(full=True)
|
|
71
|
+
writer.write_batch(batch)
|
|
72
|
+
sink.flush()
|
|
73
|
+
check()
|
|
74
|
+
return file_ref(path, "arrow", check=check)
|
|
75
|
+
except BaseException:
|
|
76
|
+
path.unlink(missing_ok=True)
|
|
77
|
+
raise
|
|
78
|
+
ref = ArtifactRef.model_validate(value) if isinstance(value, ArtifactRef) else None
|
|
79
|
+
source = local_path(ref) if ref else value
|
|
80
|
+
if not isinstance(source, Path):
|
|
81
|
+
raise TypeError("comparison input requires ArrowInput, ArtifactRef, or Path")
|
|
82
|
+
format_name = (
|
|
83
|
+
ref.format if ref else {".arrow": "arrow", ".parquet": "parquet"}.get(source.suffix.lower())
|
|
84
|
+
)
|
|
85
|
+
if format_name not in ("arrow", "parquet"):
|
|
86
|
+
raise CapabilityError("comparison supports local Arrow IPC file and Parquet")
|
|
87
|
+
path = directory / (uuid4().hex + "." + format_name)
|
|
88
|
+
digest = hashlib.sha256()
|
|
89
|
+
size = 0
|
|
90
|
+
try:
|
|
91
|
+
with source.open("rb") as reader, path.open("xb") as writer:
|
|
92
|
+
while block := reader.read(min(1024 * 1024, options.max_disk_bytes + 1)):
|
|
93
|
+
check()
|
|
94
|
+
writer.write(block)
|
|
95
|
+
writer.flush()
|
|
96
|
+
digest.update(block)
|
|
97
|
+
size += len(block)
|
|
98
|
+
check()
|
|
99
|
+
if ref and (size != ref.size_bytes or digest.hexdigest() != ref.sha256):
|
|
100
|
+
raise ArtifactIntegrityError("artifact SHA-256 or size mismatch")
|
|
101
|
+
except BaseException:
|
|
102
|
+
path.unlink(missing_ok=True)
|
|
103
|
+
raise
|
|
104
|
+
return ArtifactRef.model_validate(
|
|
105
|
+
dict(
|
|
106
|
+
schema_version="1.0",
|
|
107
|
+
uri=path.resolve().as_uri(),
|
|
108
|
+
sha256=digest.hexdigest(),
|
|
109
|
+
size_bytes=size,
|
|
110
|
+
format=format_name,
|
|
111
|
+
media_type="application/vnd.apache.arrow.file"
|
|
112
|
+
if format_name == "arrow"
|
|
113
|
+
else "application/vnd.apache.parquet",
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@contextmanager
|
|
119
|
+
def batches(
|
|
120
|
+
ref: ArtifactRef, options: ComparisonOptions, check: Callable[[], None]
|
|
121
|
+
) -> Iterator[tuple[pa.Schema, Iterator[pa.RecordBatch]]]:
|
|
122
|
+
import pyarrow as pa
|
|
123
|
+
import pyarrow.parquet as pq
|
|
124
|
+
|
|
125
|
+
with local_path(ref).open("rb") as source:
|
|
126
|
+
if ref.format == "parquet":
|
|
127
|
+
with pq.ParquetFile(source) as reader:
|
|
128
|
+
yield (
|
|
129
|
+
reader.schema_arrow,
|
|
130
|
+
_checked(
|
|
131
|
+
reader.iter_batches(batch_size=options.batch_rows, use_threads=False),
|
|
132
|
+
options,
|
|
133
|
+
check,
|
|
134
|
+
),
|
|
135
|
+
)
|
|
136
|
+
else:
|
|
137
|
+
reader = pa.ipc.open_file(source)
|
|
138
|
+
|
|
139
|
+
def iterate() -> Iterator[pa.RecordBatch]:
|
|
140
|
+
for index in range(reader.num_record_batches):
|
|
141
|
+
check()
|
|
142
|
+
batch = reader.get_batch(index)
|
|
143
|
+
# IPC file batches are indivisible at decode time. This check
|
|
144
|
+
# is cooperative and follows decode; never a process RSS quota.
|
|
145
|
+
if batch.nbytes > options.max_batch_bytes:
|
|
146
|
+
raise ResourceLimitError("IPC stored batch exceeds max_batch_bytes")
|
|
147
|
+
for start in range(0, batch.num_rows, options.batch_rows):
|
|
148
|
+
yield batch.slice(start, options.batch_rows)
|
|
149
|
+
|
|
150
|
+
yield reader.schema, _checked(iterate(), options, check)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _checked(
|
|
154
|
+
iterator: Iterator[pa.RecordBatch], options: ComparisonOptions, check: Callable[[], None]
|
|
155
|
+
) -> Iterator[pa.RecordBatch]:
|
|
156
|
+
for batch in iterator:
|
|
157
|
+
check()
|
|
158
|
+
if batch.nbytes > options.max_batch_bytes:
|
|
159
|
+
raise ResourceLimitError("max_batch_bytes")
|
|
160
|
+
batch.validate(full=True)
|
|
161
|
+
yield batch
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Versioned source and content identities for the convenience file reader."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import re
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from dataclasses import asdict, dataclass
|
|
11
|
+
from datetime import date, datetime, time, timedelta
|
|
12
|
+
from decimal import Decimal
|
|
13
|
+
from typing import Any, cast
|
|
14
|
+
|
|
15
|
+
from equiframe.errors import InvalidExecutionInput
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _digest(value: str) -> None:
|
|
19
|
+
if not isinstance(value, str) or re.fullmatch(r"[0-9a-f]{64}", value) is None:
|
|
20
|
+
raise ValueError("A lowercase SHA-256 identity is required")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class DatasetSource:
|
|
25
|
+
"""Identity of the exact bytes consumed, plus the effective reader options."""
|
|
26
|
+
|
|
27
|
+
uri: str
|
|
28
|
+
format: str
|
|
29
|
+
sha256: str
|
|
30
|
+
size_bytes: int
|
|
31
|
+
read_options_json: str
|
|
32
|
+
schema_version: str = "1.0"
|
|
33
|
+
|
|
34
|
+
def __post_init__(self) -> None:
|
|
35
|
+
_digest(self.sha256)
|
|
36
|
+
if self.schema_version != "1.0" or not self.uri:
|
|
37
|
+
raise ValueError("Unknown dataset source version or missing URI")
|
|
38
|
+
if type(self.size_bytes) is not int or self.size_bytes < 0:
|
|
39
|
+
raise ValueError("Source size must be a nonnegative integer")
|
|
40
|
+
if not isinstance(json.loads(self.read_options_json), dict):
|
|
41
|
+
raise ValueError("Reader options must be an object")
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def read_options(self) -> dict[str, Any]:
|
|
45
|
+
# Return a new object so a caller cannot mutate the recorded options.
|
|
46
|
+
return cast("dict[str, Any]", json.loads(self.read_options_json))
|
|
47
|
+
|
|
48
|
+
def to_dict(self) -> dict[str, Any]:
|
|
49
|
+
result = asdict(self)
|
|
50
|
+
result["read_options"] = result.pop("read_options_json")
|
|
51
|
+
result["read_options"] = self.read_options
|
|
52
|
+
return result
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class DatasetProvenance:
|
|
57
|
+
"""One source snapshot -> original Pandas snapshot -> native destination."""
|
|
58
|
+
|
|
59
|
+
source: DatasetSource
|
|
60
|
+
pandas_sha256: str
|
|
61
|
+
target: str
|
|
62
|
+
target_sha256: str
|
|
63
|
+
fingerprint_version: str = "equiframe-dataset-content-1.0"
|
|
64
|
+
|
|
65
|
+
def __post_init__(self) -> None:
|
|
66
|
+
_digest(self.pandas_sha256)
|
|
67
|
+
_digest(self.target_sha256)
|
|
68
|
+
if self.target not in ("pandas", "polars", "duckdb"):
|
|
69
|
+
raise ValueError("Unknown dataset destination")
|
|
70
|
+
if self.fingerprint_version != "equiframe-dataset-content-1.0":
|
|
71
|
+
raise ValueError("Unknown dataset fingerprint version")
|
|
72
|
+
|
|
73
|
+
def to_dict(self) -> dict[str, Any]:
|
|
74
|
+
return {
|
|
75
|
+
"source": self.source.to_dict(),
|
|
76
|
+
"pandas_sha256": self.pandas_sha256,
|
|
77
|
+
"target": self.target,
|
|
78
|
+
"target_sha256": self.target_sha256,
|
|
79
|
+
"fingerprint_version": self.fingerprint_version,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _value(value: Any, pandas: Any) -> Any:
|
|
84
|
+
if value is None:
|
|
85
|
+
return ["null"]
|
|
86
|
+
if value is pandas.NA:
|
|
87
|
+
return ["pandas_na"]
|
|
88
|
+
if value is pandas.NaT:
|
|
89
|
+
return ["pandas_nat"]
|
|
90
|
+
if isinstance(value, bool):
|
|
91
|
+
return ["boolean", value]
|
|
92
|
+
if isinstance(value, int):
|
|
93
|
+
return ["integer", str(value)]
|
|
94
|
+
if isinstance(value, float):
|
|
95
|
+
return ["float", "nan" if math.isnan(value) else value.hex()]
|
|
96
|
+
if isinstance(value, Decimal):
|
|
97
|
+
return ["decimal", str(value)]
|
|
98
|
+
if isinstance(value, str):
|
|
99
|
+
return ["string", value]
|
|
100
|
+
if isinstance(value, bytes):
|
|
101
|
+
return ["binary", value.hex()]
|
|
102
|
+
if isinstance(value, datetime):
|
|
103
|
+
return ["timestamp", value.isoformat(), value.fold]
|
|
104
|
+
if isinstance(value, (date, time)):
|
|
105
|
+
return [type(value).__name__, value.isoformat()]
|
|
106
|
+
if isinstance(value, pandas.Timedelta):
|
|
107
|
+
return ["duration_ns", str(value.value)]
|
|
108
|
+
if isinstance(value, timedelta):
|
|
109
|
+
return ["duration", value.days, value.seconds, value.microseconds]
|
|
110
|
+
if isinstance(value, Mapping):
|
|
111
|
+
return [
|
|
112
|
+
"object",
|
|
113
|
+
[[_value(key, pandas), _value(item, pandas)] for key, item in value.items()],
|
|
114
|
+
]
|
|
115
|
+
if isinstance(value, (list, tuple)):
|
|
116
|
+
return ["array", [_value(item, pandas) for item in value]]
|
|
117
|
+
raise InvalidExecutionInput("Dataset values cannot be fingerprinted without conversion")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _update(digest: Any, value: Any) -> None:
|
|
121
|
+
digest.update(
|
|
122
|
+
json.dumps(value, ensure_ascii=True, allow_nan=False, separators=(",", ":")).encode("ascii")
|
|
123
|
+
)
|
|
124
|
+
digest.update(b"\n")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def pandas_fingerprint(frame: Any, pandas: Any) -> str:
|
|
128
|
+
"""Identify every observed value and native dtype, without sampling."""
|
|
129
|
+
digest = hashlib.sha256()
|
|
130
|
+
_update(digest, ["equiframe-dataset-content-1.0", "pandas", len(frame)])
|
|
131
|
+
for name in frame.columns:
|
|
132
|
+
_update(digest, [name, str(frame[name].dtype)])
|
|
133
|
+
for value in frame[name].tolist():
|
|
134
|
+
_update(digest, _value(value, pandas))
|
|
135
|
+
return digest.hexdigest()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def arrow_fingerprint(table: Any, pandas: Any) -> str:
|
|
139
|
+
digest = hashlib.sha256()
|
|
140
|
+
_update(digest, ["equiframe-dataset-content-1.0", "arrow", table.num_rows])
|
|
141
|
+
for field, column in zip(table.schema, table.columns, strict=False):
|
|
142
|
+
_update(digest, [field.name, str(field.type), field.nullable])
|
|
143
|
+
for chunk in column.chunks:
|
|
144
|
+
for scalar in chunk:
|
|
145
|
+
_update(digest, _value(scalar.as_py(), pandas))
|
|
146
|
+
return digest.hexdigest()
|
equiframe/_decimal.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Isolated arithmetic for bounded exact decimals, independent of consumer state."""
|
|
2
|
+
|
|
3
|
+
from decimal import ROUND_HALF_EVEN, Context, DivisionByZero, InvalidOperation, Overflow
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def exact_decimal_context() -> Context:
|
|
7
|
+
"""Create fresh settings and flags; callers retain their own context unchanged.
|
|
8
|
+
|
|
9
|
+
One hundred digits exceed the 76-digit Arrow decimal contract and the common
|
|
10
|
+
38-digit native IR. Rounding is never permission to lose precision: ingestion
|
|
11
|
+
and IR validation compare quantized values with their exact source values.
|
|
12
|
+
"""
|
|
13
|
+
return Context(
|
|
14
|
+
prec=100,
|
|
15
|
+
rounding=ROUND_HALF_EVEN,
|
|
16
|
+
Emin=-999999,
|
|
17
|
+
Emax=999999,
|
|
18
|
+
capitals=1,
|
|
19
|
+
clamp=0,
|
|
20
|
+
flags=[],
|
|
21
|
+
traps=[InvalidOperation, DivisionByZero, Overflow],
|
|
22
|
+
)
|