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
equiframe/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from equiframe.cli import main
2
+
3
+ main()
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
+ )