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/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
|