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/datasets.py ADDED
@@ -0,0 +1,1301 @@
1
+ """Local file discovery, conservative Pandas loading and explicit data conversion.
2
+
3
+ This convenience surface converts data, not transformation programs. Engines and
4
+ file readers are imported only when selected. It does not run semantic comparison,
5
+ train models, execute macros or open a shared DuckDB database.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import csv
11
+ import hashlib
12
+ import io
13
+ import json
14
+ import math
15
+ import os
16
+ import re
17
+ from collections.abc import Mapping, Sequence
18
+ from dataclasses import asdict, dataclass, field, replace
19
+ from datetime import date, datetime, time
20
+ from decimal import Decimal, InvalidOperation
21
+ from importlib import import_module
22
+ from pathlib import Path
23
+ from types import ModuleType
24
+ from typing import Any, Literal
25
+ from uuid import uuid4
26
+
27
+ from equiframe._dataset_provenance import (
28
+ DatasetProvenance,
29
+ DatasetSource,
30
+ arrow_fingerprint,
31
+ pandas_fingerprint,
32
+ )
33
+ from equiframe.errors import CapabilityError, InvalidExecutionInput, MissingDependencyError
34
+
35
+ DatasetFormat = Literal["json", "jsonl", "excel", "txt", "xml", "csv", "parquet", "geojson"]
36
+ DatasetTarget = Literal["pandas", "polars", "duckdb"]
37
+
38
+
39
+ class DatasetReadError(InvalidExecutionInput):
40
+ """A file cannot be represented using the selected loading options."""
41
+
42
+ code = "dataset_read_error"
43
+
44
+
45
+ class DatasetChoiceRequired(DatasetReadError):
46
+ """Only a caller can resolve this structural ambiguity; no data was discarded."""
47
+
48
+ code = "dataset_choice_required"
49
+
50
+ def __init__(self, option: str, choices: Sequence[str], message: str) -> None:
51
+ self.option = option
52
+ self.choices = tuple(choices)
53
+ super().__init__(message)
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class DatasetReadOptions:
58
+ """Optional disambiguation; the usual call only needs a local file path.
59
+
60
+ Text inference accepts canonical integers and finite Decimal text. Leading
61
+ zeros, whitespace, dates, mixed representations and empty strings stay text.
62
+ ``column_types`` overrides individual columns without executing user code.
63
+ JSON ``records_path`` is an RFC 6901 JSON Pointer; XML uses ElementTree paths.
64
+ """
65
+
66
+ format: DatasetFormat | None = None
67
+ encoding: str = "utf-8-sig"
68
+ delimiter: str | None = None
69
+ header: bool = True
70
+ sheet: str | None = None
71
+ records_path: str | None = None
72
+ text_mode: Literal["auto", "lines", "delimited"] = "auto"
73
+ infer_text_types: bool = True
74
+ column_types: Mapping[str, Literal["string", "integer", "decimal", "boolean", "json"]] = field(
75
+ default_factory=dict
76
+ )
77
+ max_bytes: int = 128 * 1024 * 1024
78
+ max_rows: int = 1_000_000
79
+ max_cells: int = 2_000_000
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class DatasetColumn:
84
+ name: str
85
+ pandas_dtype: str
86
+ value_types: tuple[str, ...]
87
+ null_count: int
88
+ nan_count: int
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class DatasetInfo:
93
+ format: DatasetFormat
94
+ rows: int
95
+ columns: tuple[DatasetColumn, ...]
96
+ notes: tuple[str, ...]
97
+ metadata: Mapping[str, Any] = field(default_factory=dict)
98
+ provenance: DatasetProvenance | None = None
99
+
100
+ def to_dict(
101
+ self, *, include_metadata: bool = False, include_provenance: bool = False
102
+ ) -> dict[str, Any]:
103
+ """Describe structure; original file metadata is included only on request."""
104
+ result: dict[str, Any] = {
105
+ "format": self.format,
106
+ "rows": self.rows,
107
+ "columns": [asdict(column) for column in self.columns],
108
+ "notes": list(self.notes),
109
+ }
110
+ if include_metadata:
111
+ result["metadata"] = dict(self.metadata)
112
+ if include_provenance and self.provenance is not None:
113
+ result["provenance"] = self.provenance.to_dict()
114
+ return result
115
+
116
+
117
+ @dataclass
118
+ class DatasetResult:
119
+ """The actual Pandas input and actual native destination, with owned resources.
120
+
121
+ ``frame`` is a Pandas/Polars DataFrame or a DuckDBPyRelation. For DuckDB the
122
+ private in-memory connection must remain open while the relation is used.
123
+ Use ``with load_dataset(...) as data`` or call ``close()`` explicitly.
124
+ """
125
+
126
+ pandas: Any
127
+ frame: Any
128
+ target: DatasetTarget
129
+ info: DatasetInfo
130
+ _connection: Any = field(default=None, repr=False)
131
+ provenance: DatasetProvenance | None = None
132
+ _native_schema: tuple[tuple[str, str], ...] | None = field(default=None, init=False, repr=False)
133
+ _closed: bool = field(default=False, init=False, repr=False)
134
+
135
+ def close(self) -> None:
136
+ if not self._closed:
137
+ if self._connection is not None:
138
+ self._connection.close()
139
+ self._closed = True
140
+
141
+ def __enter__(self) -> DatasetResult:
142
+ if self._closed:
143
+ raise DatasetReadError("This dataset result has already been closed.")
144
+ return self
145
+
146
+ def __exit__(self, *arguments: object) -> None:
147
+ self.close()
148
+
149
+ def to_parquet(self, destination: str | Path) -> None:
150
+ """Atomically publish the original loaded Pandas data as a new file.
151
+
152
+ This is a source-data export. Later operations on ``frame`` are not
153
+ implicitly evaluated or substituted for that original Pandas dataset.
154
+ """
155
+ if self._closed:
156
+ raise DatasetReadError("This dataset result has already been closed.")
157
+ path = Path(destination)
158
+ if path.suffix.lower() != ".parquet":
159
+ raise DatasetReadError("Dataset export currently requires a .parquet destination.")
160
+ if path.exists() or path.is_symlink():
161
+ raise FileExistsError("Dataset export refuses to overwrite an existing destination.")
162
+ _assert_pandas_snapshot(self)
163
+ arrow = _pandas_arrow(self.pandas)
164
+ parquet = _dependency("pyarrow.parquet", "arrow")
165
+ temporary: Path | None = None
166
+ descriptor: int | None = None
167
+ try:
168
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0)
169
+ for _ in range(8):
170
+ candidate = path.parent / (".equiframe-parquet-" + uuid4().hex + ".tmp")
171
+ try:
172
+ descriptor = os.open(candidate, flags, 0o600)
173
+ except FileExistsError:
174
+ continue
175
+ temporary = candidate
176
+ break
177
+ else:
178
+ raise FileExistsError("Cannot allocate a unique Parquet temporary file.")
179
+ # Retry only actual name collisions. Windows tempfile may repeatedly
180
+ # retry PermissionError because os.access does not reflect its ACL.
181
+ with os.fdopen(descriptor, "wb") as stream:
182
+ descriptor = None # The stream now owns and closes this handle.
183
+ parquet.write_table(arrow, stream)
184
+ stream.flush()
185
+ os.fsync(stream.fileno())
186
+ # A same-filesystem hard link publishes only the complete file and
187
+ # fails atomically if another writer created the destination first.
188
+ # Do not fall back to rename/replace or expose a partially copied file.
189
+ os.link(temporary, path)
190
+ finally:
191
+ try:
192
+ if descriptor is not None:
193
+ os.close(descriptor)
194
+ finally:
195
+ if temporary is not None:
196
+ temporary.unlink(missing_ok=True)
197
+
198
+
199
+ def load_dataset(
200
+ source: str | Path,
201
+ *,
202
+ target: DatasetTarget = "pandas",
203
+ options: DatasetReadOptions | None = None,
204
+ ) -> DatasetResult:
205
+ """Read a local file into Pandas, optionally convert that DataFrame to a target.
206
+
207
+ CSV/JSON/JSONL/TXT/XML/Excel/Parquet/GeoJSON are supported. Ambiguous Excel
208
+ sheets, JSON record collections and TXT delimiters require an explicit option.
209
+ There is no network access, automatic dependency install or silent fallback.
210
+ """
211
+ if target not in ("pandas", "polars", "duckdb"):
212
+ raise DatasetReadError("target must be pandas, polars or duckdb.")
213
+ opts = options or DatasetReadOptions()
214
+ _validate_options(opts)
215
+ path = Path(source)
216
+ if not path.is_file():
217
+ raise DatasetReadError("source must be an existing local regular file.")
218
+ if path.stat().st_size > opts.max_bytes:
219
+ raise DatasetReadError("The source exceeds max_bytes; increase the explicit limit.")
220
+ kind = _format(path, opts.format)
221
+ payload = _read_bytes(path, opts.max_bytes)
222
+ pd = _dependency("pandas", "pandas")
223
+ notes: list[str] = []
224
+ metadata: dict[str, Any] = {}
225
+ if kind == "parquet":
226
+ parquet = _dependency("pyarrow.parquet", "arrow")
227
+ with io.BytesIO(payload) as stream:
228
+ reader = parquet.ParquetFile(stream)
229
+ if reader.metadata.num_rows > opts.max_rows:
230
+ raise DatasetReadError("The Parquet file exceeds max_rows.")
231
+ if reader.metadata.num_rows * len(reader.schema_arrow) > opts.max_cells:
232
+ raise DatasetReadError("The Parquet table exceeds max_cells.")
233
+ table = reader.read()
234
+ _column_names(table.column_names)
235
+ frame = table.to_pandas(types_mapper=pd.ArrowDtype)
236
+ notes.append("Parquet Arrow types, temporal metadata and null masks are retained.")
237
+ frame = _override_frame(frame, opts, pd, notes)
238
+ else:
239
+ if kind == "excel":
240
+ rows, names, metadata = _excel_rows(path, opts, notes, payload=payload)
241
+ else:
242
+ if kind == "xml":
243
+ rows, names, metadata = _xml_rows(payload, opts)
244
+ else:
245
+ try:
246
+ text = payload.decode(opts.encoding)
247
+ except (UnicodeError, LookupError) as error:
248
+ raise DatasetReadError(
249
+ "Cannot decode this file; set encoding explicitly."
250
+ ) from error
251
+ if kind in ("json", "jsonl", "geojson"):
252
+ rows, names, metadata = _json_rows(text, kind, opts)
253
+ else:
254
+ rows, names, metadata = _text_rows(text, kind, opts, notes)
255
+ _check_dimensions(rows, names, opts)
256
+ _column_names(names)
257
+ columns: dict[str, Any] = {}
258
+ for index, name in enumerate(names):
259
+ values = [row[index] for row in rows]
260
+ is_free_text = (
261
+ kind == "txt"
262
+ and names == ["text"]
263
+ and any(note.startswith("Free text:") for note in notes)
264
+ )
265
+ if kind in ("csv", "txt", "xml") and opts.infer_text_types and not is_free_text:
266
+ values, inferred = _infer_text(values)
267
+ if inferred:
268
+ notes.append(f"Column {name!r}: inferred {inferred} without float parsing.")
269
+ if name in opts.column_types:
270
+ values = _convert_column(values, opts.column_types[name])
271
+ notes.append(f"Column {name!r}: explicit {opts.column_types[name]} conversion.")
272
+ columns[name] = _series(values, pd, opts.column_types.get(name))
273
+ unknown = set(opts.column_types).difference(names)
274
+ if unknown:
275
+ raise DatasetReadError("column_types contains a column absent from the file.")
276
+ frame = pd.DataFrame(columns, index=range(len(rows)))
277
+ info = _describe(frame, kind, notes, metadata, pd)
278
+ effective = asdict(opts)
279
+ effective["format"] = kind
280
+ for key in ("sheet", "delimiter", "header", "text_mode"):
281
+ if key in metadata:
282
+ effective[key] = metadata[key]
283
+ source_identity = DatasetSource(
284
+ uri=path.absolute().as_uri(),
285
+ format=kind,
286
+ sha256=hashlib.sha256(payload).hexdigest(),
287
+ size_bytes=len(payload),
288
+ read_options_json=json.dumps(
289
+ effective, ensure_ascii=True, sort_keys=True, separators=(",", ":")
290
+ ),
291
+ )
292
+ fingerprint = pandas_fingerprint(frame, pd)
293
+ provenance = DatasetProvenance(source_identity, fingerprint, "pandas", fingerprint)
294
+ info = replace(info, provenance=provenance)
295
+ original = DatasetResult(frame, frame, "pandas", info, provenance=provenance)
296
+ return original if target == "pandas" else convert_dataset(original, target=target)
297
+
298
+
299
+ def inspect_dataset(
300
+ source: str | Path, *, options: DatasetReadOptions | None = None
301
+ ) -> DatasetInfo:
302
+ """Load and describe the complete bounded dataset; this is not a sample scan."""
303
+ with load_dataset(source, options=options) as result:
304
+ return result.info
305
+
306
+
307
+ def convert_dataset(dataset: DatasetResult, *, target: DatasetTarget) -> DatasetResult:
308
+ """Convert the current native frame without rereading the file.
309
+
310
+ The original Pandas snapshot remains the source identity. Chained
311
+ conversions consume the current Pandas, Polars or DuckDB frame and verify
312
+ that neither that frame nor the original snapshot changed after capture.
313
+ """
314
+ if dataset._closed:
315
+ raise DatasetReadError("Cannot convert a closed dataset result.")
316
+ if target not in ("pandas", "polars", "duckdb"):
317
+ raise DatasetReadError("target must be pandas, polars or duckdb.")
318
+ _assert_pandas_snapshot(dataset)
319
+ table = _current_dataset_table(dataset)
320
+ if target == "pandas":
321
+ if dataset.target == "pandas":
322
+ return _converted_result(dataset, dataset.frame, target)
323
+ pd = _dependency("pandas", "pandas")
324
+ # Rebuild from the actual native values, retaining the source's Pandas
325
+ # dtypes and their nullable masks, instead of returning an old frame.
326
+ pandas_frame = pd.DataFrame(
327
+ {
328
+ name: pd.Series(table[name].to_pylist(), dtype=dataset.pandas[name].dtype)
329
+ for name in table.column_names
330
+ },
331
+ index=range(table.num_rows),
332
+ )
333
+ _assert_conversion(table, _pandas_arrow(pandas_frame), "Pandas")
334
+ return _converted_result(dataset, pandas_frame, target)
335
+ if target == "polars":
336
+ polars = _dependency("polars", "polars")
337
+ try:
338
+ frame = polars.from_arrow(table, rechunk=True)
339
+ _assert_conversion(table, frame.to_arrow(), "Polars")
340
+ except Exception as error:
341
+ if isinstance(error, CapabilityError):
342
+ raise
343
+ raise CapabilityError(
344
+ "Polars cannot represent this inferred schema faithfully."
345
+ ) from error
346
+ return _converted_result(dataset, frame, target, table=frame.to_arrow())
347
+ duckdb = _dependency("duckdb", "duckdb")
348
+ connection = duckdb.connect(
349
+ database=":memory:",
350
+ config={"autoinstall_known_extensions": "false", "autoload_known_extensions": "false"},
351
+ )
352
+ try:
353
+ relation = connection.from_arrow(table)
354
+ # Materialize now: constructing a lazy relation alone does not prove that
355
+ # DuckDB can consume all values of this Arrow schema.
356
+ materialized = relation.to_arrow_table()
357
+ _assert_conversion(table, materialized, "DuckDB")
358
+ native = connection.from_arrow(materialized)
359
+ return _converted_result(dataset, native, target, table=materialized, connection=connection)
360
+ except Exception as error:
361
+ connection.close()
362
+ if isinstance(error, CapabilityError):
363
+ raise
364
+ raise CapabilityError("DuckDB cannot represent this inferred schema faithfully.") from error
365
+
366
+
367
+ def _current_dataset_table(dataset: DatasetResult) -> Any:
368
+ """Capture the selected native input and reject stale or altered identity."""
369
+ pd = _dependency("pandas", "pandas")
370
+ if dataset.target == "pandas":
371
+ if not isinstance(dataset.frame, pd.DataFrame):
372
+ raise DatasetReadError("The selected Pandas frame is not a Pandas DataFrame.")
373
+ table = _pandas_arrow(dataset.frame)
374
+ digest = None
375
+ if dataset.provenance is not None:
376
+ digest = (
377
+ dataset.provenance.pandas_sha256
378
+ if dataset.frame is dataset.pandas
379
+ else pandas_fingerprint(dataset.frame, pd)
380
+ )
381
+ elif dataset.target == "polars":
382
+ pl = _dependency("polars", "polars")
383
+ if not isinstance(dataset.frame, pl.DataFrame):
384
+ raise DatasetReadError("The selected Polars frame is not a Polars DataFrame.")
385
+ _assert_native_schema(
386
+ dataset, tuple((name, str(dtype)) for name, dtype in dataset.frame.schema.items())
387
+ )
388
+ if dataset.frame.height != dataset.info.rows:
389
+ raise DatasetReadError("The native frame row count changed after loading.")
390
+ table = dataset.frame.to_arrow()
391
+ digest = arrow_fingerprint(table, pd) if dataset.provenance is not None else None
392
+ elif dataset.target == "duckdb":
393
+ duckdb = _dependency("duckdb", "duckdb")
394
+ if not isinstance(dataset.frame, duckdb.DuckDBPyRelation):
395
+ raise DatasetReadError("The selected DuckDB frame is not a DuckDB relation.")
396
+ # Arrow alone cannot establish native type fidelity: for example,
397
+ # UHUGEINT may wrap to a negative decimal128 during DuckDB export.
398
+ # Compare the captured native schema before executing/converting it.
399
+ _assert_native_schema(
400
+ dataset,
401
+ tuple(zip(dataset.frame.columns, map(str, dataset.frame.types), strict=True)),
402
+ )
403
+ table = dataset.frame.limit(dataset.info.rows + 1).to_arrow_table()
404
+ digest = arrow_fingerprint(table, pd) if dataset.provenance is not None else None
405
+ else:
406
+ raise DatasetReadError("The dataset input must be pandas, polars or duckdb.")
407
+ if dataset.provenance is not None and (
408
+ dataset.provenance.target != dataset.target or digest != dataset.provenance.target_sha256
409
+ ):
410
+ raise DatasetReadError(
411
+ "The native frame changed after loading; capture modified data as a new input "
412
+ "before conversion instead of reusing its earlier identity."
413
+ )
414
+ if dataset.frame is not dataset.pandas:
415
+ _assert_conversion(_pandas_arrow(dataset.pandas), table, "Native input")
416
+ return table
417
+
418
+
419
+ def _assert_native_schema(dataset: DatasetResult, observed: tuple[tuple[str, str], ...]) -> None:
420
+ if dataset._native_schema is None or observed != dataset._native_schema:
421
+ raise DatasetReadError(
422
+ "The native schema changed or was not captured; reload the source "
423
+ "before conversion instead of reusing an unverified native identity."
424
+ )
425
+
426
+
427
+ def _assert_pandas_snapshot(dataset: DatasetResult) -> None:
428
+ if dataset.provenance is not None:
429
+ pd = _dependency("pandas", "pandas")
430
+ if pandas_fingerprint(dataset.pandas, pd) != dataset.provenance.pandas_sha256:
431
+ raise DatasetReadError(
432
+ "The original Pandas data changed after loading; reload the source or "
433
+ "capture the modified data as a separate execution before conversion/export."
434
+ )
435
+
436
+
437
+ def _converted_result(
438
+ original: DatasetResult,
439
+ frame: Any,
440
+ target: DatasetTarget,
441
+ *,
442
+ table: Any = None,
443
+ connection: Any = None,
444
+ ) -> DatasetResult:
445
+ native_schema = None
446
+ if target == "polars":
447
+ native_schema = tuple((name, str(dtype)) for name, dtype in frame.schema.items())
448
+ elif target == "duckdb":
449
+ native_schema = tuple(zip(frame.columns, map(str, frame.types), strict=True))
450
+ provenance = original.provenance
451
+ if provenance is not None:
452
+ digest = (
453
+ pandas_fingerprint(frame, _dependency("pandas", "pandas"))
454
+ if target == "pandas"
455
+ else arrow_fingerprint(table, _dependency("pandas", "pandas"))
456
+ )
457
+ provenance = DatasetProvenance(provenance.source, provenance.pandas_sha256, target, digest)
458
+ result = DatasetResult(
459
+ original.pandas,
460
+ frame,
461
+ target,
462
+ replace(original.info, provenance=provenance),
463
+ _connection=connection,
464
+ provenance=provenance,
465
+ )
466
+ result._native_schema = native_schema
467
+ return result
468
+
469
+
470
+ def _arrow_type_equivalent(left: Any, right: Any, pa: ModuleType) -> bool:
471
+ if left.equals(right):
472
+ return True
473
+ if (pa.types.is_string(left) or pa.types.is_large_string(left)) and (
474
+ pa.types.is_string(right) or pa.types.is_large_string(right)
475
+ ):
476
+ return True
477
+ if (pa.types.is_binary(left) or pa.types.is_large_binary(left)) and (
478
+ pa.types.is_binary(right) or pa.types.is_large_binary(right)
479
+ ):
480
+ return True
481
+ if (pa.types.is_list(left) or pa.types.is_large_list(left)) and (
482
+ pa.types.is_list(right) or pa.types.is_large_list(right)
483
+ ):
484
+ return _arrow_type_equivalent(left.value_type, right.value_type, pa)
485
+ if pa.types.is_struct(left) and pa.types.is_struct(right) and len(left) == len(right):
486
+ return all(
487
+ first.name == second.name and _arrow_type_equivalent(first.type, second.type, pa)
488
+ for first, second in zip(left, right, strict=False)
489
+ )
490
+ return False
491
+
492
+
493
+ def _same_value(left: Any, right: Any) -> bool:
494
+ if left is None or right is None:
495
+ return left is None and right is None
496
+ if isinstance(left, Decimal):
497
+ return isinstance(right, Decimal) and left.as_tuple() == right.as_tuple()
498
+ if isinstance(left, float):
499
+ if not isinstance(right, float):
500
+ return False
501
+ if math.isnan(left):
502
+ return math.isnan(right)
503
+ if left == 0.0 and right == 0.0:
504
+ return math.copysign(1.0, left) == math.copysign(1.0, right)
505
+ return left == right
506
+ if isinstance(left, Mapping):
507
+ return (
508
+ isinstance(right, Mapping)
509
+ and left.keys() == right.keys()
510
+ and all(_same_value(value, right[key]) for key, value in left.items())
511
+ )
512
+ if isinstance(left, (list, tuple)):
513
+ return (
514
+ isinstance(right, (list, tuple))
515
+ and len(left) == len(right)
516
+ and all(_same_value(first, second) for first, second in zip(left, right, strict=False))
517
+ )
518
+ return type(left) is type(right) and bool(left == right)
519
+
520
+
521
+ def _assert_conversion(before: Any, after: Any, engine: str) -> None:
522
+ """Read the full native result back; hashes or samples cannot establish fidelity."""
523
+ pa = _dependency("pyarrow", "arrow")
524
+ if before.num_rows != after.num_rows or before.column_names != after.column_names:
525
+ raise CapabilityError(f"{engine} conversion changed the row count or column names/order.")
526
+ for expected, actual in zip(before.schema, after.schema, strict=False):
527
+ if not _arrow_type_equivalent(expected.type, actual.type, pa):
528
+ raise CapabilityError(
529
+ f"{engine} changed a column type, decimal scale, temporal unit or timezone; "
530
+ "choose an explicit compatible input representation."
531
+ )
532
+ for name in before.column_names:
533
+ expected_values = before[name].to_pylist()
534
+ actual_values = after[name].to_pylist()
535
+ if any(
536
+ not _same_value(first, second)
537
+ for first, second in zip(expected_values, actual_values, strict=False)
538
+ ):
539
+ raise CapabilityError(
540
+ f"{engine} conversion changed a value, null/NaN distinction or row order."
541
+ )
542
+
543
+
544
+ def _dependency(module: str, extra: str) -> ModuleType:
545
+ try:
546
+ return import_module(module)
547
+ except ImportError as error:
548
+ raise MissingDependencyError(
549
+ f"This operation requires {module}; install the equiframe[{extra}] optional extra."
550
+ ) from error
551
+ except (RuntimeError, OSError) as error:
552
+ if module == "polars" and os.name == "nt" and not os.environ.get("PROCESSOR_ARCHITECTURE"):
553
+ raise DatasetReadError(
554
+ "Polars could not initialize without the Windows PROCESSOR_ARCHITECTURE "
555
+ "environment variable; preserve the standard Windows process environment."
556
+ ) from error
557
+ raise CapabilityError(
558
+ f"The installed {module} dependency could not initialize; "
559
+ "check its runtime and the operating-system environment."
560
+ ) from error
561
+
562
+
563
+ def _validate_options(options: DatasetReadOptions) -> None:
564
+ if not isinstance(options, DatasetReadOptions):
565
+ raise DatasetReadError("options must be DatasetReadOptions.")
566
+ for value in (options.max_bytes, options.max_rows, options.max_cells):
567
+ if type(value) is not int or value < 1:
568
+ raise DatasetReadError("max_bytes, max_rows and max_cells must be positive integers.")
569
+ if type(options.header) is not bool or type(options.infer_text_types) is not bool:
570
+ raise DatasetReadError("header and infer_text_types must be booleans.")
571
+ if not isinstance(options.encoding, str) or not options.encoding:
572
+ raise DatasetReadError("encoding must be a nonempty string.")
573
+ if options.format is not None and not isinstance(options.format, str):
574
+ raise DatasetReadError("format must be a documented format name.")
575
+ if options.sheet is not None and not isinstance(options.sheet, str):
576
+ raise DatasetReadError("sheet must be an Excel sheet name.")
577
+ if options.records_path is not None and not isinstance(options.records_path, str):
578
+ raise DatasetReadError("records_path must be a string.")
579
+ if options.delimiter is not None and (
580
+ not isinstance(options.delimiter, str)
581
+ or len(options.delimiter) != 1
582
+ or options.delimiter in "\r\n\x00"
583
+ ):
584
+ raise DatasetReadError("delimiter must be one non-newline character.")
585
+ if options.text_mode not in ("auto", "lines", "delimited"):
586
+ raise DatasetReadError("text_mode must be auto, lines or delimited.")
587
+ if not isinstance(options.column_types, Mapping) or any(
588
+ not isinstance(name, str) or not name for name in options.column_types
589
+ ):
590
+ raise DatasetReadError("column_types must map nonempty column names to types.")
591
+ if any(
592
+ value not in ("string", "integer", "decimal", "boolean", "json")
593
+ for value in options.column_types.values()
594
+ ):
595
+ raise DatasetReadError("column_types supports string, integer, decimal, boolean and json.")
596
+
597
+
598
+ def _format(path: Path, explicit: DatasetFormat | None) -> DatasetFormat:
599
+ extensions: dict[str, DatasetFormat] = {
600
+ ".json": "json",
601
+ ".jsonl": "jsonl",
602
+ ".ndjson": "jsonl",
603
+ ".xlsx": "excel",
604
+ ".xlsm": "excel",
605
+ ".xls": "excel",
606
+ ".txt": "txt",
607
+ ".tsv": "txt",
608
+ ".xml": "xml",
609
+ ".csv": "csv",
610
+ ".parquet": "parquet",
611
+ ".geojson": "geojson",
612
+ }
613
+ if explicit is not None:
614
+ if explicit not in set(extensions.values()):
615
+ raise DatasetReadError("Unsupported format; use a documented dataset format.")
616
+ return explicit
617
+ if path.suffix.lower() not in extensions:
618
+ raise DatasetChoiceRequired(
619
+ "format", sorted(set(extensions.values())), "Specify the format of this file."
620
+ )
621
+ return extensions[path.suffix.lower()]
622
+
623
+
624
+ def _read_bytes(path: Path, maximum: int) -> bytes:
625
+ with path.open("rb") as stream:
626
+ data = stream.read(maximum + 1)
627
+ if len(data) > maximum:
628
+ raise DatasetReadError("The source exceeds max_bytes.")
629
+ return data
630
+
631
+
632
+ def _column_names(names: Sequence[str]) -> None:
633
+ if any(not isinstance(name, str) or not name for name in names):
634
+ raise DatasetReadError("Every column must have a nonempty string name.")
635
+ if len(set(names)) != len(names):
636
+ raise DatasetReadError("Duplicate column names are refused; select a unique header.")
637
+
638
+
639
+ def _check_dimensions(rows: Sequence[Any], names: Sequence[str], opts: DatasetReadOptions) -> None:
640
+ if len(rows) > opts.max_rows or len(rows) * len(names) > opts.max_cells:
641
+ raise DatasetReadError("The dataset exceeds max_rows or max_cells.")
642
+
643
+
644
+ def _pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
645
+ value: dict[str, Any] = {}
646
+ for key, item in pairs:
647
+ if key in value:
648
+ raise DatasetReadError("Duplicate JSON object members are refused.")
649
+ value[key] = item
650
+ return value
651
+
652
+
653
+ def _constant(value: str) -> None:
654
+ raise DatasetReadError("JSON NaN and Infinity tokens are not valid JSON numbers.")
655
+
656
+
657
+ def _decode_json(text: str) -> Any:
658
+ try:
659
+ return json.loads(
660
+ text, parse_float=Decimal, object_pairs_hook=_pairs, parse_constant=_constant
661
+ )
662
+ except (ValueError, RecursionError) as error:
663
+ raise DatasetReadError("Invalid or excessively nested JSON input.") from error
664
+
665
+
666
+ def _pointer(root: Any, pointer: str) -> Any:
667
+ if pointer == "":
668
+ return root
669
+ if not pointer.startswith("/"):
670
+ raise DatasetReadError("JSON records_path must be an RFC 6901 pointer such as /data/items.")
671
+ value = root
672
+ try:
673
+ for token in pointer[1:].split("/"):
674
+ token = token.replace("~1", "/").replace("~0", "~")
675
+ value = value[int(token)] if isinstance(value, list) else value[token]
676
+ return value
677
+ except (KeyError, IndexError, TypeError, ValueError) as error:
678
+ raise DatasetReadError(
679
+ "JSON records_path does not identify an available collection."
680
+ ) from error
681
+
682
+
683
+ def _json_rows(
684
+ text: str, kind: DatasetFormat, opts: DatasetReadOptions
685
+ ) -> tuple[list[list[Any]], list[str], dict[str, Any]]:
686
+ if kind == "jsonl":
687
+ records: Any = []
688
+ for line in text.splitlines():
689
+ if line.strip():
690
+ records.append(_decode_json(line))
691
+ if len(records) > opts.max_rows:
692
+ raise DatasetReadError("The JSONL file exceeds max_rows.")
693
+ if opts.records_path is not None:
694
+ raise DatasetReadError("records_path is not used with JSONL records.")
695
+ else:
696
+ records = _decode_json(text)
697
+ if kind == "geojson" or (
698
+ isinstance(records, dict) and records.get("type") in ("FeatureCollection", "Feature")
699
+ ):
700
+ if opts.records_path is not None:
701
+ raise DatasetReadError("GeoJSON uses its feature structure; do not set records_path.")
702
+ return _geojson_rows(records, opts)
703
+ if opts.records_path is not None:
704
+ records = _pointer(records, opts.records_path)
705
+ elif isinstance(records, dict):
706
+ candidates = _record_paths(records)
707
+ if candidates:
708
+ raise DatasetChoiceRequired(
709
+ "records_path",
710
+ candidates,
711
+ "Choose the JSON record collection; "
712
+ "nested collections are not expanded implicitly.",
713
+ )
714
+ if isinstance(records, dict) or not isinstance(records, list):
715
+ records = [records]
716
+ if any(not isinstance(record, dict) for record in records):
717
+ _check_dimensions(records, ["value"], opts)
718
+ return (
719
+ [[record] for record in records],
720
+ ["value"],
721
+ {"json_layout": "one_value_per_row"},
722
+ )
723
+ names = _mapping_names(records)
724
+ absent = {
725
+ name: [index for index, record in enumerate(records) if name not in record]
726
+ for name in names
727
+ if any(name not in record for record in records)
728
+ }
729
+ metadata: dict[str, Any] = {}
730
+ if absent:
731
+ metadata["absent_json_members_by_row"] = absent
732
+ return _mapping_rows(records, opts), names, metadata
733
+
734
+
735
+ def _record_paths(root: dict[str, Any]) -> list[str]:
736
+ candidates: list[str] = []
737
+ pending = [("", root)]
738
+ while pending:
739
+ parent, record = pending.pop()
740
+ for key, value in record.items():
741
+ path = parent + "/" + key.replace("~", "~0").replace("/", "~1")
742
+ if isinstance(value, dict):
743
+ pending.append((path, value))
744
+ elif isinstance(value, list) and value and all(isinstance(row, dict) for row in value):
745
+ candidates.append(path)
746
+ if len(candidates) > 64:
747
+ raise DatasetReadError(
748
+ "Too many JSON record collections to list; set records_path explicitly."
749
+ )
750
+ return candidates
751
+
752
+
753
+ def _mapping_names(records: Any) -> list[str]:
754
+ if not isinstance(records, list) or any(not isinstance(row, dict) for row in records):
755
+ raise DatasetReadError(
756
+ "A JSON table must be a record object or an array of record objects."
757
+ )
758
+ names: dict[str, None] = {}
759
+ for record in records:
760
+ for key in record:
761
+ if not isinstance(key, str) or not key:
762
+ raise DatasetReadError("Record fields must have nonempty string names.")
763
+ names[key] = None
764
+ return list(names)
765
+
766
+
767
+ def _mapping_rows(records: Any, opts: DatasetReadOptions) -> list[list[Any]]:
768
+ names = _mapping_names(records)
769
+ _check_dimensions(records, names, opts)
770
+ return [[record.get(name) for name in names] for record in records]
771
+
772
+
773
+ def _geojson_rows(
774
+ value: Any, opts: DatasetReadOptions
775
+ ) -> tuple[list[list[Any]], list[str], dict[str, Any]]:
776
+ if not isinstance(value, dict):
777
+ raise DatasetReadError("GeoJSON must contain an object.")
778
+ geometry_types = {
779
+ "Point",
780
+ "MultiPoint",
781
+ "LineString",
782
+ "MultiLineString",
783
+ "Polygon",
784
+ "MultiPolygon",
785
+ "GeometryCollection",
786
+ }
787
+ if value.get("type") in geometry_types:
788
+ return [[value]], ["geometry"], {"geojson_type": value["type"]}
789
+ metadata: dict[str, Any] = {"geojson_type": value.get("type")}
790
+ if value.get("type") == "FeatureCollection":
791
+ features = value.get("features")
792
+ metadata["collection_members"] = {
793
+ key: item for key, item in value.items() if key != "features"
794
+ }
795
+ elif value.get("type") == "Feature":
796
+ features = [value]
797
+ else:
798
+ raise DatasetReadError("Expected a GeoJSON FeatureCollection, Feature or geometry object.")
799
+ if not isinstance(features, list):
800
+ raise DatasetReadError("GeoJSON features must be an array.")
801
+ records: list[dict[str, Any]] = []
802
+ for feature in features:
803
+ if not isinstance(feature, dict) or feature.get("type") != "Feature":
804
+ raise DatasetReadError("Every GeoJSON collection member must be a Feature.")
805
+ properties = feature.get("properties")
806
+ geometry = feature.get("geometry")
807
+ if properties is not None and not isinstance(properties, dict):
808
+ raise DatasetReadError("GeoJSON properties must be an object or null.")
809
+ if geometry is not None and (
810
+ not isinstance(geometry, dict) or geometry.get("type") not in geometry_types
811
+ ):
812
+ raise DatasetReadError("GeoJSON geometry must be a geometry object or null.")
813
+ record = {"geometry": geometry, "properties": properties}
814
+ if properties is not None:
815
+ record.update({"properties." + key: item for key, item in properties.items()})
816
+ record.update(
817
+ {
818
+ "feature." + key: item
819
+ for key, item in feature.items()
820
+ if key not in ("geometry", "properties")
821
+ }
822
+ )
823
+ records.append(record)
824
+ return _mapping_rows(records, opts), _mapping_names(records), metadata
825
+
826
+
827
+ def _text_rows(
828
+ text: str, kind: DatasetFormat, opts: DatasetReadOptions, notes: list[str]
829
+ ) -> tuple[list[list[Any]], list[str], dict[str, Any]]:
830
+ if kind == "txt" and opts.text_mode == "lines":
831
+ notes.append("Free text: one line per row, without interpreting or dropping blank lines.")
832
+ return (
833
+ [[line] for line in text.splitlines()],
834
+ ["text"],
835
+ {"text_mode": "lines", "header": False, "delimiter": None},
836
+ )
837
+ delimiter = opts.delimiter
838
+ if delimiter is None:
839
+ candidates: list[str] = []
840
+ for candidate in (",", ";", "\t", "|"):
841
+ try:
842
+ parsed = []
843
+ for row in csv.reader(io.StringIO(text), delimiter=candidate, strict=True):
844
+ parsed.append(row)
845
+ if len(parsed) == 64:
846
+ break
847
+ widths = {len(row) for row in parsed if row}
848
+ if len(widths) == 1 and next(iter(widths), 0) > 1:
849
+ candidates.append(candidate)
850
+ except csv.Error:
851
+ continue
852
+ if len(candidates) == 1:
853
+ if kind == "txt" and opts.text_mode == "auto":
854
+ raise DatasetChoiceRequired(
855
+ "text_mode",
856
+ ["delimited", "lines"],
857
+ "TXT may be a table or free text; choose how its lines should be interpreted.",
858
+ )
859
+ delimiter = candidates[0]
860
+ notes.append(
861
+ "Detected a consistent delimited structure; header=True unless overridden."
862
+ )
863
+ elif candidates:
864
+ raise DatasetChoiceRequired(
865
+ "delimiter", candidates, "Several delimiters fit; choose one explicitly."
866
+ )
867
+ elif kind == "txt" and opts.text_mode == "auto":
868
+ notes.append(
869
+ "Free text: one line per row; set text_mode='delimited' to require a table."
870
+ )
871
+ return (
872
+ [[line] for line in text.splitlines()],
873
+ ["text"],
874
+ {"text_mode": "lines", "header": False, "delimiter": None},
875
+ )
876
+ elif kind == "csv":
877
+ delimiter = ","
878
+ else:
879
+ raise DatasetChoiceRequired(
880
+ "delimiter",
881
+ [",", ";", "\t", "|"],
882
+ "Set the TXT delimiter or choose text_mode='lines'.",
883
+ )
884
+ rows: list[list[Any]] = []
885
+ try:
886
+ for row in csv.reader(io.StringIO(text, newline=""), delimiter=delimiter, strict=True):
887
+ rows.append(row)
888
+ if len(rows) > opts.max_rows + int(opts.header):
889
+ raise DatasetReadError("The delimited file exceeds max_rows.")
890
+ except csv.Error as error:
891
+ raise DatasetReadError("Malformed delimited input; rows were not skipped.") from error
892
+ if not rows:
893
+ raise DatasetReadError("An empty delimited file has no discoverable columns.")
894
+ names = rows.pop(0) if opts.header else [f"column_{index + 1}" for index in range(len(rows[0]))]
895
+ if any(len(row) != len(names) for row in rows):
896
+ raise DatasetReadError(
897
+ "Ragged or blank delimited records are refused; rows were not padded or skipped."
898
+ )
899
+ notes.append("Empty text fields remain empty strings; dates and null tokens are not guessed.")
900
+ return rows, names, {"text_mode": "delimited", "header": opts.header, "delimiter": delimiter}
901
+
902
+
903
+ def _xml_value(element: Any) -> Any:
904
+ children = list(element)
905
+ if not children and not element.attrib:
906
+ return element.text if element.text is not None else ""
907
+ record: dict[str, Any] = {"@" + key: item for key, item in element.attrib.items()}
908
+ if element.text is not None and element.text.strip():
909
+ record["#text"] = element.text
910
+ for child in children:
911
+ child_value = _xml_value(child)
912
+ if child.tag in record:
913
+ previous = record[child.tag]
914
+ record[child.tag] = (
915
+ [*previous, child_value] if isinstance(previous, list) else [previous, child_value]
916
+ )
917
+ else:
918
+ record[child.tag] = child_value
919
+ if child.tail is not None and child.tail.strip():
920
+ raise DatasetReadError(
921
+ "Mixed XML text after child elements needs an explicit preprocessing step."
922
+ )
923
+ return record
924
+
925
+
926
+ def _xml_rows(
927
+ payload: bytes, opts: DatasetReadOptions
928
+ ) -> tuple[list[list[Any]], list[str], dict[str, Any]]:
929
+ safe_xml = _dependency("defusedxml.ElementTree", "files")
930
+ try:
931
+ root = safe_xml.fromstring(
932
+ payload, forbid_dtd=True, forbid_entities=True, forbid_external=True
933
+ )
934
+ except Exception as error:
935
+ raise DatasetReadError(
936
+ "XML must be well-formed and contain no DTD, entities or external references."
937
+ ) from error
938
+ if opts.records_path is not None:
939
+ try:
940
+ elements = root.findall(opts.records_path)
941
+ except (SyntaxError, KeyError) as error:
942
+ raise DatasetReadError(
943
+ "Invalid XML records_path; use an ElementTree element path."
944
+ ) from error
945
+ if not elements:
946
+ raise DatasetReadError("XML records_path selected no elements.")
947
+ else:
948
+ children = list(root)
949
+ elements = children if children and len({child.tag for child in children}) == 1 else [root]
950
+ if elements is children and (
951
+ (root.text is not None and root.text.strip())
952
+ or any(child.tail is not None and child.tail.strip() for child in children)
953
+ ):
954
+ raise DatasetReadError(
955
+ "Mixed XML container text cannot be discarded while selecting child records."
956
+ )
957
+ records: list[dict[str, Any]] = []
958
+ for element in elements:
959
+ value = _xml_value(element)
960
+ records.append(value if isinstance(value, dict) else {"text": value})
961
+ metadata = {"xml_root": root.tag, "xml_root_attributes": dict(root.attrib)}
962
+ return _mapping_rows(records, opts), _mapping_names(records), metadata
963
+
964
+
965
+ def _choose_sheet(names: Sequence[str], selected: str | None) -> str:
966
+ if selected is None:
967
+ if len(names) != 1:
968
+ raise DatasetChoiceRequired("sheet", names, "Choose the Excel sheet to load.")
969
+ return names[0]
970
+ if selected not in names:
971
+ raise DatasetChoiceRequired("sheet", names, "The requested Excel sheet is not available.")
972
+ return selected
973
+
974
+
975
+ def _excel_rows(
976
+ path: Path, opts: DatasetReadOptions, notes: list[str], *, payload: bytes | None = None
977
+ ) -> tuple[list[list[Any]], list[str], dict[str, Any]]:
978
+ rows: list[list[Any]] = []
979
+ number_formats: dict[str, set[str]] = {}
980
+ width = 0
981
+
982
+ def append_row(row: list[Any]) -> None:
983
+ nonlocal width
984
+ next_width = max(width, len(row))
985
+ data_rows = max(0, len(rows) + 1 - int(opts.header))
986
+ if data_rows > opts.max_rows or next_width * max(1, data_rows) > opts.max_cells:
987
+ raise DatasetReadError("The Excel sheet exceeds max_rows or max_cells.")
988
+ width = next_width
989
+ rows.append(row)
990
+
991
+ if path.suffix.lower() == ".xls":
992
+ xlrd = _dependency("xlrd", "files")
993
+ book = (
994
+ xlrd.open_workbook(str(path), on_demand=True)
995
+ if payload is None
996
+ else xlrd.open_workbook(file_contents=payload, on_demand=True)
997
+ )
998
+ try:
999
+ name = _choose_sheet(book.sheet_names(), opts.sheet)
1000
+ sheet = book.sheet_by_name(name)
1001
+ data_rows = max(0, sheet.nrows - int(opts.header))
1002
+ if data_rows > opts.max_rows or sheet.ncols * max(1, data_rows) > opts.max_cells:
1003
+ raise DatasetReadError("The Excel sheet exceeds max_rows or max_cells.")
1004
+ for row_index in range(sheet.nrows):
1005
+ row: list[Any] = []
1006
+ for cell in sheet.row(row_index):
1007
+ if cell.ctype == xlrd.XL_CELL_ERROR:
1008
+ raise DatasetReadError("Excel error cells are refused.")
1009
+ if cell.ctype == xlrd.XL_CELL_DATE:
1010
+ value = xlrd.xldate.xldate_as_datetime(cell.value, book.datemode)
1011
+ elif cell.ctype == xlrd.XL_CELL_BOOLEAN:
1012
+ value = bool(cell.value)
1013
+ elif cell.ctype in (xlrd.XL_CELL_EMPTY, xlrd.XL_CELL_BLANK):
1014
+ value = None
1015
+ else:
1016
+ value = cell.value
1017
+ row.append(value)
1018
+ append_row(row)
1019
+ notes.append(
1020
+ "Legacy XLS exposes stored/cached cell values; "
1021
+ "formulas cannot be independently identified by this reader."
1022
+ )
1023
+ finally:
1024
+ book.release_resources()
1025
+ else:
1026
+ openpyxl = _dependency("openpyxl", "files")
1027
+ book = openpyxl.load_workbook(
1028
+ path if payload is None else io.BytesIO(payload),
1029
+ read_only=True,
1030
+ data_only=False,
1031
+ keep_links=False,
1032
+ )
1033
+ try:
1034
+ name = _choose_sheet(book.sheetnames, opts.sheet)
1035
+ sheet = book[name]
1036
+ # Worksheet dimensions are optional producer metadata and can be
1037
+ # smaller than the actual stored cells. Never use them to truncate
1038
+ # rows/columns; enforce budgets against each observed row instead.
1039
+ sheet.reset_dimensions()
1040
+ for row_index, cells in enumerate(sheet.iter_rows()):
1041
+ if row_index + 1 - int(opts.header) > opts.max_rows:
1042
+ raise DatasetReadError("The Excel sheet exceeds max_rows or max_cells.")
1043
+ row = []
1044
+ for index, cell in enumerate(cells):
1045
+ if cell.data_type in ("f", "e"):
1046
+ raise DatasetReadError(
1047
+ "Excel formula/error cells are refused; supply a values-only workbook."
1048
+ )
1049
+ value = cell.value
1050
+ fmt = cell.number_format
1051
+ if row_index >= int(opts.header) and value is not None:
1052
+ number_formats.setdefault(str(index), set()).add(fmt)
1053
+ if (
1054
+ isinstance(value, (int, float))
1055
+ and not isinstance(value, bool)
1056
+ and re.fullmatch(r"0{2,}", fmt)
1057
+ and float(value).is_integer()
1058
+ ):
1059
+ value = str(int(value)).zfill(len(fmt))
1060
+ notes.append("Excel zero-padded numeric display retained as text.")
1061
+ row.append(value)
1062
+ append_row(row)
1063
+ finally:
1064
+ book.close()
1065
+ if not rows:
1066
+ raise DatasetReadError("The selected Excel sheet has no discoverable columns.")
1067
+ if opts.header:
1068
+ names = rows.pop(0)
1069
+ if len(names) != width or any(not isinstance(item, str) or not item for item in names):
1070
+ raise DatasetReadError(
1071
+ "Excel header cells must be nonempty strings; "
1072
+ "use header=False when no header exists."
1073
+ )
1074
+ else:
1075
+ names = [f"column_{index + 1}" for index in range(width)]
1076
+ for row in rows:
1077
+ row.extend([None] * (width - len(row)))
1078
+ notes.append(
1079
+ "Excel stored numeric values retain the workbook's numeric precision; "
1080
+ "formatting does not recover lost digits."
1081
+ )
1082
+ return (
1083
+ rows,
1084
+ names,
1085
+ {
1086
+ "sheet": name,
1087
+ "number_formats_by_position": {
1088
+ key: sorted(value) for key, value in number_formats.items()
1089
+ },
1090
+ },
1091
+ )
1092
+
1093
+
1094
+ def _infer_text(values: list[Any]) -> tuple[list[Any], str | None]:
1095
+ present = [value for value in values if value is not None]
1096
+ if not present or any(not isinstance(value, str) or not value for value in present):
1097
+ return values, None
1098
+ integer = re.compile(r"-?(?:0|[1-9][0-9]*)\Z")
1099
+ if all(integer.fullmatch(value) and value != "-0" for value in present):
1100
+ return [None if value is None else int(value) for value in values], "integer"
1101
+ numeric = re.compile(r"-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\Z")
1102
+ if all(numeric.fullmatch(value) for value in present):
1103
+ decimals = [None if value is None else Decimal(value) for value in values]
1104
+ if all(value is None or value.is_finite() for value in decimals):
1105
+ return decimals, "decimal"
1106
+ return values, None
1107
+
1108
+
1109
+ def _convert_column(values: list[Any], kind: str) -> list[Any]:
1110
+ converted: list[Any] = []
1111
+ for value in values:
1112
+ if value is None:
1113
+ converted.append(None)
1114
+ continue
1115
+ try:
1116
+ if kind == "string":
1117
+ if isinstance(value, (dict, list)):
1118
+ raise ValueError("Nested data cannot be implicitly stringified.")
1119
+ item: Any = str(value)
1120
+ elif kind == "json":
1121
+ item = _json_text(value)
1122
+ elif kind == "integer":
1123
+ if isinstance(value, (bool, float)):
1124
+ raise ValueError("Boolean/float to integer requires explicit preprocessing.")
1125
+ item = int(value)
1126
+ if isinstance(value, Decimal) and value != item:
1127
+ raise ValueError("Fractional integer conversion.")
1128
+ elif kind == "decimal":
1129
+ if isinstance(value, (bool, float)):
1130
+ raise ValueError("Binary float to exact Decimal is not automatic.")
1131
+ item = Decimal(value)
1132
+ if not item.is_finite():
1133
+ raise ValueError("Nonfinite Decimal.")
1134
+ elif kind == "boolean":
1135
+ if isinstance(value, bool):
1136
+ item = value
1137
+ elif value in ("true", "false"):
1138
+ item = value == "true"
1139
+ else:
1140
+ raise ValueError("Boolean values must be true or false.")
1141
+ else:
1142
+ raise ValueError("Unsupported explicit column type.")
1143
+ except (ValueError, TypeError, InvalidOperation, OverflowError) as error:
1144
+ raise DatasetReadError(
1145
+ "An explicit column conversion cannot preserve a value."
1146
+ ) from error
1147
+ converted.append(item)
1148
+ return converted
1149
+
1150
+
1151
+ def _json_text(value: Any) -> str:
1152
+ """Explicit JSON-text representation, keeping Decimal numbers out of floats."""
1153
+ if value is None:
1154
+ return "null"
1155
+ if isinstance(value, bool):
1156
+ return "true" if value else "false"
1157
+ if isinstance(value, int):
1158
+ return str(value)
1159
+ if isinstance(value, Decimal):
1160
+ if not value.is_finite():
1161
+ raise ValueError("JSON cannot represent a nonfinite Decimal.")
1162
+ return str(value)
1163
+ if isinstance(value, float):
1164
+ return json.dumps(value, allow_nan=False)
1165
+ if isinstance(value, str):
1166
+ return json.dumps(value, ensure_ascii=True)
1167
+ if isinstance(value, (list, tuple)):
1168
+ return "[" + ",".join(_json_text(item) for item in value) + "]"
1169
+ if isinstance(value, Mapping) and all(isinstance(key, str) for key in value):
1170
+ return (
1171
+ "{"
1172
+ + ",".join(
1173
+ json.dumps(key, ensure_ascii=True) + ":" + _json_text(item)
1174
+ for key, item in value.items()
1175
+ )
1176
+ + "}"
1177
+ )
1178
+ raise ValueError("This value has no declared JSON representation.")
1179
+
1180
+
1181
+ def _series(values: list[Any], pandas: ModuleType, declared: str | None = None) -> Any:
1182
+ present = [value for value in values if value is not None]
1183
+ if not present:
1184
+ if declared in ("string", "json"):
1185
+ return pandas.Series(values, dtype=pandas.StringDtype(storage="python"))
1186
+ if declared == "integer":
1187
+ return pandas.Series(values, dtype="Int64")
1188
+ if declared == "boolean":
1189
+ return pandas.Series(values, dtype="boolean")
1190
+ if present and all(isinstance(value, bool) for value in present):
1191
+ return pandas.Series(values, dtype="boolean")
1192
+ if present and all(type(value) is int for value in present):
1193
+ low, high = min(present), max(present)
1194
+ if low >= -(2**63) and high < 2**63:
1195
+ return pandas.Series(values, dtype="Int64")
1196
+ if low >= 0 and high < 2**64:
1197
+ return pandas.Series(values, dtype="UInt64")
1198
+ if present and all(isinstance(value, str) for value in present):
1199
+ return pandas.Series(values, dtype=pandas.StringDtype(storage="python"))
1200
+ # object preserves Decimal, nested values, datetime objects and the distinction
1201
+ # between a None and a floating NaN; no binary float intermediate is introduced.
1202
+ return pandas.Series(values, dtype=object)
1203
+
1204
+
1205
+ def _override_frame(frame: Any, opts: DatasetReadOptions, pd: ModuleType, notes: list[str]) -> Any:
1206
+ if set(opts.column_types).difference(frame.columns):
1207
+ raise DatasetReadError("column_types contains a column absent from the file.")
1208
+ for name, kind in opts.column_types.items():
1209
+ values = [
1210
+ None if item is pd.NA or item is pd.NaT else item for item in frame[name].tolist()
1211
+ ]
1212
+ frame[name] = _series(_convert_column(values, kind), pd, kind)
1213
+ notes.append(f"Column {name!r}: explicit {kind} conversion.")
1214
+ return frame
1215
+
1216
+
1217
+ def _value_type(value: Any) -> str:
1218
+ if isinstance(value, bool):
1219
+ return "boolean"
1220
+ if isinstance(value, Decimal):
1221
+ return "decimal"
1222
+ if isinstance(value, int):
1223
+ return "integer"
1224
+ if isinstance(value, float):
1225
+ return "float"
1226
+ if isinstance(value, datetime):
1227
+ return "timestamp"
1228
+ if isinstance(value, date):
1229
+ return "date"
1230
+ if isinstance(value, time):
1231
+ return "time"
1232
+ if isinstance(value, str):
1233
+ return "string"
1234
+ if isinstance(value, Mapping):
1235
+ return "object"
1236
+ if isinstance(value, (list, tuple)):
1237
+ return "array"
1238
+ if isinstance(value, bytes):
1239
+ return "binary"
1240
+ return type(value).__name__
1241
+
1242
+
1243
+ def _describe(
1244
+ frame: Any, kind: DatasetFormat, notes: list[str], metadata: dict[str, Any], pd: ModuleType
1245
+ ) -> DatasetInfo:
1246
+ columns: list[DatasetColumn] = []
1247
+ for name in frame.columns:
1248
+ values = frame[name].tolist()
1249
+ nonnull = [
1250
+ value
1251
+ for value in values
1252
+ if value is not None and value is not pd.NA and value is not pd.NaT
1253
+ ]
1254
+ columns.append(
1255
+ DatasetColumn(
1256
+ name,
1257
+ str(frame[name].dtype),
1258
+ tuple(sorted({_value_type(value) for value in nonnull})),
1259
+ len(values) - len(nonnull),
1260
+ sum(isinstance(value, float) and math.isnan(value) for value in nonnull),
1261
+ )
1262
+ )
1263
+ return DatasetInfo(kind, len(frame), tuple(columns), tuple(dict.fromkeys(notes)), metadata)
1264
+
1265
+
1266
+ def _pandas_arrow(frame: Any) -> Any:
1267
+ pa = _dependency("pyarrow", "arrow")
1268
+ pd = _dependency("pandas", "pandas")
1269
+ arrays = []
1270
+ if len(frame.columns) == 0 and len(frame) > 0:
1271
+ raise CapabilityError("A nonempty dataset with no columns cannot be converted faithfully.")
1272
+ try:
1273
+ for name in frame.columns:
1274
+ series = frame[name]
1275
+ if not pd.api.types.is_object_dtype(series.dtype):
1276
+ # Preserve signedness, widths and nullable extension masks,
1277
+ # including an explicitly typed column containing only nulls.
1278
+ arrays.append(pa.array(series.array, from_pandas=False))
1279
+ else:
1280
+ values = [
1281
+ None if value is pd.NA or value is pd.NaT else value
1282
+ for value in series.tolist()
1283
+ ]
1284
+ array = pa.array(values, from_pandas=False)
1285
+ if any(
1286
+ not _same_value(original, represented)
1287
+ for original, represented in zip(values, array.to_pylist(), strict=False)
1288
+ ):
1289
+ raise CapabilityError(
1290
+ "Arrow inference would change a value or nested field presence; "
1291
+ "declare column_types explicitly (json preserves nested JSON as text)."
1292
+ )
1293
+ arrays.append(array)
1294
+ return pa.Table.from_arrays(arrays, names=list(frame.columns))
1295
+ except Exception as error:
1296
+ if isinstance(error, CapabilityError):
1297
+ raise
1298
+ raise CapabilityError(
1299
+ "The Pandas columns cannot be represented faithfully in Arrow; "
1300
+ "declare compatible column_types or preprocess explicitly."
1301
+ ) from error