dnakit 0.1.0.dev0__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.
- dnakit/__init__.py +108 -0
- dnakit/_version.py +5 -0
- dnakit/alignment/__init__.py +6 -0
- dnakit/alignment/pairwise.py +512 -0
- dnakit/alignment/results.py +188 -0
- dnakit/backends/__init__.py +80 -0
- dnakit/backends/external.py +517 -0
- dnakit/backends/registry.py +207 -0
- dnakit/batch.py +452 -0
- dnakit/cache/__init__.py +5 -0
- dnakit/cache/store.py +424 -0
- dnakit/chunking.py +920 -0
- dnakit/cli/__init__.py +5 -0
- dnakit/cli/__main__.py +6 -0
- dnakit/cli/app.py +814 -0
- dnakit/cli/workflow.py +94 -0
- dnakit/config/__init__.py +5 -0
- dnakit/config/loader.py +381 -0
- dnakit/core/__init__.py +80 -0
- dnakit/core/_json.py +206 -0
- dnakit/core/backend_info.py +70 -0
- dnakit/core/collection.py +163 -0
- dnakit/core/coordinates.py +295 -0
- dnakit/core/enums.py +95 -0
- dnakit/core/facade.py +719 -0
- dnakit/core/feature.py +90 -0
- dnakit/core/gap.py +82 -0
- dnakit/core/issues.py +62 -0
- dnakit/core/provenance.py +305 -0
- dnakit/core/record.py +161 -0
- dnakit/core/results.py +134 -0
- dnakit/core/sequence.py +327 -0
- dnakit/datasets/__init__.py +100 -0
- dnakit/datasets/_advanced_shared.py +198 -0
- dnakit/datasets/_metadata.py +33 -0
- dnakit/datasets/advanced_deduplicate.py +183 -0
- dnakit/datasets/advanced_split.py +302 -0
- dnakit/datasets/clustering.py +286 -0
- dnakit/datasets/config.py +531 -0
- dnakit/datasets/deduplicate.py +303 -0
- dnakit/datasets/evaluation.py +244 -0
- dnakit/datasets/filter.py +135 -0
- dnakit/datasets/neural_clustering.py +221 -0
- dnakit/datasets/results.py +520 -0
- dnakit/datasets/split.py +450 -0
- dnakit/descriptors/__init__.py +102 -0
- dnakit/descriptors/_dinucleotide.py +345 -0
- dnakit/descriptors/_shared.py +180 -0
- dnakit/descriptors/all.py +917 -0
- dnakit/descriptors/basic.py +193 -0
- dnakit/descriptors/codon.py +195 -0
- dnakit/descriptors/complexity.py +201 -0
- dnakit/descriptors/entropy.py +101 -0
- dnakit/descriptors/homopolymer.py +105 -0
- dnakit/descriptors/kmer.py +88 -0
- dnakit/descriptors/results.py +286 -0
- dnakit/descriptors/schema.py +414 -0
- dnakit/descriptors/window.py +203 -0
- dnakit/download/__init__.py +54 -0
- dnakit/download/catalogs.py +166 -0
- dnakit/download/ena.py +131 -0
- dnakit/download/encode.py +103 -0
- dnakit/download/files.py +469 -0
- dnakit/download/indexes.py +233 -0
- dnakit/download/metadata.py +164 -0
- dnakit/download/models.py +227 -0
- dnakit/download/ncbi.py +333 -0
- dnakit/download/sequence.py +133 -0
- dnakit/download/ucsc.py +99 -0
- dnakit/evaluation/__init__.py +81 -0
- dnakit/evaluation/_shared.py +288 -0
- dnakit/evaluation/collection.py +270 -0
- dnakit/evaluation/config.py +591 -0
- dnakit/evaluation/distribution.py +287 -0
- dnakit/evaluation/frechet.py +223 -0
- dnakit/evaluation/generative.py +381 -0
- dnakit/evaluation/reference.py +409 -0
- dnakit/evaluation/results.py +137 -0
- dnakit/evaluation/scorecard.py +211 -0
- dnakit/evaluation/sequence.py +403 -0
- dnakit/evaluation/synthesis.py +249 -0
- dnakit/exceptions.py +189 -0
- dnakit/fingerprints/__init__.py +109 -0
- dnakit/fingerprints/_shared.py +116 -0
- dnakit/fingerprints/advanced.py +829 -0
- dnakit/fingerprints/bit.py +229 -0
- dnakit/fingerprints/encoding.py +351 -0
- dnakit/fingerprints/kmer.py +170 -0
- dnakit/fingerprints/results.py +531 -0
- dnakit/fingerprints/sketch.py +246 -0
- dnakit/io/__init__.py +112 -0
- dnakit/io/_advanced_common.py +105 -0
- dnakit/io/_formats.py +994 -0
- dnakit/io/annotations.py +1179 -0
- dnakit/io/api.py +759 -0
- dnakit/io/config.py +315 -0
- dnakit/io/genbank.py +523 -0
- dnakit/io/indexed.py +1146 -0
- dnakit/io/metadata.py +452 -0
- dnakit/io/parquet.py +56 -0
- dnakit/io/results.py +131 -0
- dnakit/io/source.py +138 -0
- dnakit/io/tables.py +1237 -0
- dnakit/molbio/__init__.py +101 -0
- dnakit/molbio/_shared.py +266 -0
- dnakit/molbio/assembly.py +279 -0
- dnakit/molbio/crispr.py +181 -0
- dnakit/molbio/optimization.py +897 -0
- dnakit/molbio/primer3_design.py +667 -0
- dnakit/molbio/primers.py +899 -0
- dnakit/molbio/restriction.py +573 -0
- dnakit/molbio/results.py +583 -0
- dnakit/ops/__init__.py +588 -0
- dnakit/ops/_common.py +201 -0
- dnakit/ops/circular.py +193 -0
- dnakit/ops/concat.py +204 -0
- dnakit/ops/direction.py +27 -0
- dnakit/ops/edit.py +443 -0
- dnakit/ops/evolution.py +991 -0
- dnakit/ops/mutation.py +151 -0
- dnakit/ops/records.py +1012 -0
- dnakit/ops/translation.py +257 -0
- dnakit/patterns/__init__.py +81 -0
- dnakit/patterns/_shared.py +282 -0
- dnakit/patterns/coding.py +360 -0
- dnakit/patterns/crispr.py +337 -0
- dnakit/patterns/motif.py +721 -0
- dnakit/patterns/regions.py +341 -0
- dnakit/patterns/repeats.py +480 -0
- dnakit/patterns/restriction.py +281 -0
- dnakit/patterns/results.py +411 -0
- dnakit/py.typed +1 -0
- dnakit/references/__init__.py +27 -0
- dnakit/references/models.py +155 -0
- dnakit/references/ncbi.py +785 -0
- dnakit/representations/__init__.py +29 -0
- dnakit/representations/backends.py +928 -0
- dnakit/representations/checkpoints.py +356 -0
- dnakit/representations/extraction.py +220 -0
- dnakit/representations/models.py +395 -0
- dnakit/representations/results.py +79 -0
- dnakit/search/__init__.py +132 -0
- dnakit/search/_http.py +271 -0
- dnakit/search/_shared.py +108 -0
- dnakit/search/blast.py +516 -0
- dnakit/search/ena.py +245 -0
- dnakit/search/encode.py +102 -0
- dnakit/search/ensembl.py +874 -0
- dnakit/search/models.py +159 -0
- dnakit/search/ncbi.py +756 -0
- dnakit/search/ucsc.py +388 -0
- dnakit/secondary_structure/__init__.py +38 -0
- dnakit/secondary_structure/dotbracket.py +410 -0
- dnakit/secondary_structure/nupack.py +589 -0
- dnakit/secondary_structure/results.py +142 -0
- dnakit/similarity/__init__.py +125 -0
- dnakit/similarity/_shared.py +152 -0
- dnakit/similarity/approximate.py +258 -0
- dnakit/similarity/compare.py +162 -0
- dnakit/similarity/dashing.py +936 -0
- dnakit/similarity/distance.py +279 -0
- dnakit/similarity/index.py +365 -0
- dnakit/similarity/matrix.py +254 -0
- dnakit/similarity/results.py +676 -0
- dnakit/similarity/search.py +286 -0
- dnakit/similarity/sketch.py +96 -0
- dnakit/similarity/vector.py +662 -0
- dnakit/standardize/__init__.py +77 -0
- dnakit/standardize/_shared.py +100 -0
- dnakit/standardize/config.py +411 -0
- dnakit/standardize/gaps.py +1060 -0
- dnakit/standardize/normalize.py +565 -0
- dnakit/standardize/results.py +247 -0
- dnakit/standardize/validate.py +487 -0
- dnakit/structure3d/__init__.py +47 -0
- dnakit/structure3d/_shared.py +47 -0
- dnakit/structure3d/geometry.py +773 -0
- dnakit/structure3d/pdb.py +253 -0
- dnakit/structure3d/results.py +233 -0
- dnakit/structure3d/threedna.py +174 -0
- dnakit/thermodynamics/__init__.py +112 -0
- dnakit/thermodynamics/_shared.py +92 -0
- dnakit/thermodynamics/backends.py +653 -0
- dnakit/thermodynamics/calculations.py +837 -0
- dnakit/thermodynamics/config.py +175 -0
- dnakit/thermodynamics/equilibrium.py +340 -0
- dnakit/thermodynamics/optics.py +385 -0
- dnakit/thermodynamics/parameters.py +79 -0
- dnakit/thermodynamics/results.py +445 -0
- dnakit/visualization/__init__.py +43 -0
- dnakit/visualization/_svg.py +185 -0
- dnakit/visualization/advanced.py +314 -0
- dnakit/visualization/config.py +255 -0
- dnakit/visualization/export.py +349 -0
- dnakit/visualization/matrix.py +279 -0
- dnakit/visualization/report.py +431 -0
- dnakit/visualization/results.py +236 -0
- dnakit/visualization/sequence.py +459 -0
- dnakit/workflows/__init__.py +45 -0
- dnakit/workflows/manifest.py +365 -0
- dnakit/workflows/runner.py +1548 -0
- dnakit/workflows/schema.py +898 -0
- dnakit-0.1.0.dev0.dist-info/METADATA +126 -0
- dnakit-0.1.0.dev0.dist-info/RECORD +210 -0
- dnakit-0.1.0.dev0.dist-info/WHEEL +5 -0
- dnakit-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- dnakit-0.1.0.dev0.dist-info/licenses/DISCLAIMER.md +26 -0
- dnakit-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
- dnakit-0.1.0.dev0.dist-info/licenses/THIRD_PARTY_NOTICES.md +73 -0
- dnakit-0.1.0.dev0.dist-info/top_level.txt +1 -0
dnakit/io/tables.py
ADDED
|
@@ -0,0 +1,1237 @@
|
|
|
1
|
+
"""Strict, bounded, atomic export for result objects and two-dimensional tables."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
import math
|
|
9
|
+
import os
|
|
10
|
+
import platform
|
|
11
|
+
import re
|
|
12
|
+
import tempfile
|
|
13
|
+
from collections.abc import Iterable, Iterator, Mapping, Sequence
|
|
14
|
+
from contextlib import contextmanager
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from itertools import islice
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, BinaryIO, Literal, Protocol, TextIO, cast, runtime_checkable
|
|
19
|
+
|
|
20
|
+
from dnakit.core._json import FrozenDict, JSONScalar, freeze_mapping, to_json_compatible
|
|
21
|
+
from dnakit.exceptions import (
|
|
22
|
+
BackendExecutionError,
|
|
23
|
+
ConfigurationError,
|
|
24
|
+
DNAKitError,
|
|
25
|
+
InputFormatError,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
from ._advanced_common import write_text_path
|
|
29
|
+
from .parquet import ParquetBackendStatus, require_parquet_backend
|
|
30
|
+
|
|
31
|
+
TableFormat = Literal["csv", "tsv", "json", "parquet"]
|
|
32
|
+
ParquetCompression = Literal["none", "snappy", "gzip", "brotli", "lz4", "zstd"]
|
|
33
|
+
TableScalar = JSONScalar
|
|
34
|
+
TableColumnType = Literal["any", "string", "integer", "number", "boolean"]
|
|
35
|
+
_MAX_TABLE_SCHEMA_COLUMNS = 10_000
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@runtime_checkable
|
|
39
|
+
class DictResult(Protocol):
|
|
40
|
+
"""Structural protocol for serializable DNAKit result objects."""
|
|
41
|
+
|
|
42
|
+
def to_dict(self) -> dict[str, Any]: ...
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _validate_columns(columns: Iterable[str]) -> tuple[str, ...]:
|
|
46
|
+
if isinstance(columns, (str, bytes)):
|
|
47
|
+
raise ConfigurationError(
|
|
48
|
+
"Table columns must be an iterable of column names.",
|
|
49
|
+
code="INVALID_TABLE_SCHEMA",
|
|
50
|
+
)
|
|
51
|
+
try:
|
|
52
|
+
resolved = tuple(islice(iter(columns), _MAX_TABLE_SCHEMA_COLUMNS + 1))
|
|
53
|
+
except TypeError as exc:
|
|
54
|
+
raise ConfigurationError(
|
|
55
|
+
"Table columns must be an iterable of column names.",
|
|
56
|
+
code="INVALID_TABLE_SCHEMA",
|
|
57
|
+
) from exc
|
|
58
|
+
if len(resolved) > _MAX_TABLE_SCHEMA_COLUMNS:
|
|
59
|
+
raise ConfigurationError(
|
|
60
|
+
"Table schema exceeds the hard column limit.",
|
|
61
|
+
code="INVALID_TABLE_SCHEMA",
|
|
62
|
+
context={"max_columns": _MAX_TABLE_SCHEMA_COLUMNS},
|
|
63
|
+
)
|
|
64
|
+
if not resolved or any(
|
|
65
|
+
not isinstance(column, str)
|
|
66
|
+
or not column
|
|
67
|
+
or "\n" in column
|
|
68
|
+
or "\r" in column
|
|
69
|
+
or "\t" in column
|
|
70
|
+
for column in resolved
|
|
71
|
+
):
|
|
72
|
+
raise ConfigurationError(
|
|
73
|
+
"Table columns must be non-empty single-line strings without tabs.",
|
|
74
|
+
code="INVALID_TABLE_SCHEMA",
|
|
75
|
+
)
|
|
76
|
+
if len(set(resolved)) != len(resolved):
|
|
77
|
+
raise ConfigurationError("Table columns must be unique.", code="INVALID_TABLE_SCHEMA")
|
|
78
|
+
return resolved
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _validate_scalar(value: object, *, row_index: int, column: str) -> TableScalar:
|
|
82
|
+
if value is None or isinstance(value, (bool, int, str)):
|
|
83
|
+
return value
|
|
84
|
+
if isinstance(value, float) and math.isfinite(value):
|
|
85
|
+
return value
|
|
86
|
+
raise InputFormatError(
|
|
87
|
+
"Table cells must be JSON scalar values.",
|
|
88
|
+
code="INVALID_TABLE_CELL",
|
|
89
|
+
context={
|
|
90
|
+
"row_index": row_index,
|
|
91
|
+
"column": column,
|
|
92
|
+
"value_type": type(value).__name__,
|
|
93
|
+
},
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _validate_schema_scalar(
|
|
98
|
+
value: object, *, schema: TableSchema, row_index: int, column: str
|
|
99
|
+
) -> TableScalar:
|
|
100
|
+
scalar = _validate_scalar(value, row_index=row_index, column=column)
|
|
101
|
+
if scalar is None:
|
|
102
|
+
if column not in cast(tuple[str, ...], schema.nullable):
|
|
103
|
+
raise InputFormatError(
|
|
104
|
+
"A non-nullable table column contains a missing value.",
|
|
105
|
+
code="TABLE_NULL_NOT_ALLOWED",
|
|
106
|
+
context={"row_index": row_index, "column": column},
|
|
107
|
+
)
|
|
108
|
+
return None
|
|
109
|
+
expected = schema.column_type(column)
|
|
110
|
+
valid = (
|
|
111
|
+
expected == "any"
|
|
112
|
+
or (expected == "string" and isinstance(scalar, str))
|
|
113
|
+
or (expected == "integer" and isinstance(scalar, int) and not isinstance(scalar, bool))
|
|
114
|
+
or (
|
|
115
|
+
expected == "number"
|
|
116
|
+
and isinstance(scalar, (int, float))
|
|
117
|
+
and not isinstance(scalar, bool)
|
|
118
|
+
)
|
|
119
|
+
or (expected == "boolean" and isinstance(scalar, bool))
|
|
120
|
+
)
|
|
121
|
+
if not valid:
|
|
122
|
+
raise InputFormatError(
|
|
123
|
+
"Table cell does not match its declared column type.",
|
|
124
|
+
code="TABLE_COLUMN_TYPE_MISMATCH",
|
|
125
|
+
context={
|
|
126
|
+
"row_index": row_index,
|
|
127
|
+
"column": column,
|
|
128
|
+
"expected": expected,
|
|
129
|
+
"actual": type(scalar).__name__,
|
|
130
|
+
},
|
|
131
|
+
)
|
|
132
|
+
return scalar
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass(frozen=True, slots=True)
|
|
136
|
+
class TableSchema:
|
|
137
|
+
"""Explicit stable column order and optional semantic schema version."""
|
|
138
|
+
|
|
139
|
+
columns: tuple[str, ...]
|
|
140
|
+
schema_version: str = "dnakit.table.v1"
|
|
141
|
+
column_types: Mapping[str, TableColumnType] = field(
|
|
142
|
+
default_factory=lambda: cast(Mapping[str, TableColumnType], FrozenDict())
|
|
143
|
+
)
|
|
144
|
+
nullable: tuple[str, ...] | None = None
|
|
145
|
+
|
|
146
|
+
def __post_init__(self) -> None:
|
|
147
|
+
object.__setattr__(self, "columns", _validate_columns(self.columns))
|
|
148
|
+
if not isinstance(self.schema_version, str) or not self.schema_version.strip():
|
|
149
|
+
raise ConfigurationError(
|
|
150
|
+
"Table schema_version must be non-empty.", code="INVALID_TABLE_SCHEMA"
|
|
151
|
+
)
|
|
152
|
+
if not isinstance(self.column_types, Mapping):
|
|
153
|
+
raise ConfigurationError(
|
|
154
|
+
"Table column_types must be a mapping.", code="INVALID_TABLE_SCHEMA"
|
|
155
|
+
)
|
|
156
|
+
raw_types = dict(self.column_types)
|
|
157
|
+
if any(column not in self.columns for column in raw_types) or any(
|
|
158
|
+
value not in ("any", "string", "integer", "number", "boolean")
|
|
159
|
+
for value in raw_types.values()
|
|
160
|
+
):
|
|
161
|
+
raise ConfigurationError(
|
|
162
|
+
"Table column_types keys and values must match the declared schema.",
|
|
163
|
+
code="INVALID_TABLE_SCHEMA",
|
|
164
|
+
)
|
|
165
|
+
object.__setattr__(self, "column_types", freeze_mapping(raw_types))
|
|
166
|
+
nullable = self.columns if self.nullable is None else tuple(self.nullable)
|
|
167
|
+
if (
|
|
168
|
+
any(not isinstance(column, str) for column in nullable)
|
|
169
|
+
or len(set(nullable)) != len(nullable)
|
|
170
|
+
or any(column not in self.columns for column in nullable)
|
|
171
|
+
):
|
|
172
|
+
raise ConfigurationError(
|
|
173
|
+
"Table nullable columns must be unique declared columns.",
|
|
174
|
+
code="INVALID_TABLE_SCHEMA",
|
|
175
|
+
)
|
|
176
|
+
object.__setattr__(self, "nullable", nullable)
|
|
177
|
+
|
|
178
|
+
def column_type(self, column: str) -> TableColumnType:
|
|
179
|
+
"""Return one column's strict scalar type, defaulting to ``any``."""
|
|
180
|
+
|
|
181
|
+
return self.column_types.get(column, "any")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@dataclass(frozen=True, slots=True)
|
|
185
|
+
class TableExportResult:
|
|
186
|
+
format: TableFormat
|
|
187
|
+
row_count: int
|
|
188
|
+
columns: tuple[str, ...]
|
|
189
|
+
schema_version: str
|
|
190
|
+
target_path: str
|
|
191
|
+
byte_count: int
|
|
192
|
+
parameters: FrozenDict
|
|
193
|
+
|
|
194
|
+
def __post_init__(self) -> None:
|
|
195
|
+
if self.format not in {"csv", "tsv", "json", "parquet"}:
|
|
196
|
+
raise ConfigurationError(
|
|
197
|
+
"Table export result has an invalid format.", code="INVALID_TABLE_RESULT"
|
|
198
|
+
)
|
|
199
|
+
_validate_columns(self.columns)
|
|
200
|
+
if not isinstance(self.schema_version, str) or not self.schema_version:
|
|
201
|
+
raise ConfigurationError(
|
|
202
|
+
"Table export schema_version must be non-empty.",
|
|
203
|
+
code="INVALID_TABLE_RESULT",
|
|
204
|
+
)
|
|
205
|
+
if not isinstance(self.target_path, str) or not self.target_path:
|
|
206
|
+
raise ConfigurationError(
|
|
207
|
+
"Table export target_path must be non-empty.", code="INVALID_TABLE_RESULT"
|
|
208
|
+
)
|
|
209
|
+
if any(
|
|
210
|
+
isinstance(value, bool) or not isinstance(value, int) or value < 0
|
|
211
|
+
for value in (self.row_count, self.byte_count)
|
|
212
|
+
):
|
|
213
|
+
raise ConfigurationError(
|
|
214
|
+
"Table export counts must be non-negative integers.",
|
|
215
|
+
code="INVALID_TABLE_RESULT",
|
|
216
|
+
)
|
|
217
|
+
if not isinstance(self.parameters, FrozenDict):
|
|
218
|
+
raise ConfigurationError(
|
|
219
|
+
"Table export parameters must be an immutable mapping.",
|
|
220
|
+
code="INVALID_TABLE_RESULT",
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
def to_dict(self) -> dict[str, Any]:
|
|
224
|
+
return cast(dict[str, Any], to_json_compatible(self))
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@dataclass(frozen=True, slots=True)
|
|
228
|
+
class TableReadResult:
|
|
229
|
+
"""Immutable rows and audit metadata returned by :func:`read_table`."""
|
|
230
|
+
|
|
231
|
+
format: TableFormat
|
|
232
|
+
rows: tuple[FrozenDict, ...]
|
|
233
|
+
schema: TableSchema
|
|
234
|
+
source_path: str | None
|
|
235
|
+
byte_count: int
|
|
236
|
+
parameters: FrozenDict
|
|
237
|
+
|
|
238
|
+
def __post_init__(self) -> None:
|
|
239
|
+
if self.format not in {"csv", "tsv", "json", "parquet"}:
|
|
240
|
+
raise ConfigurationError(
|
|
241
|
+
"Table read result has an invalid format.", code="INVALID_TABLE_RESULT"
|
|
242
|
+
)
|
|
243
|
+
if not isinstance(self.rows, tuple) or any(
|
|
244
|
+
not isinstance(row, FrozenDict) for row in self.rows
|
|
245
|
+
):
|
|
246
|
+
raise ConfigurationError(
|
|
247
|
+
"Table read rows must be an immutable tuple of FrozenDict values.",
|
|
248
|
+
code="INVALID_TABLE_RESULT",
|
|
249
|
+
)
|
|
250
|
+
if not isinstance(self.schema, TableSchema):
|
|
251
|
+
raise ConfigurationError(
|
|
252
|
+
"Table read schema must be a TableSchema.", code="INVALID_TABLE_RESULT"
|
|
253
|
+
)
|
|
254
|
+
if any(tuple(row) != self.schema.columns for row in self.rows):
|
|
255
|
+
raise ConfigurationError(
|
|
256
|
+
"Table read row keys and order must match its schema.",
|
|
257
|
+
code="INVALID_TABLE_RESULT",
|
|
258
|
+
)
|
|
259
|
+
if self.source_path is not None and (
|
|
260
|
+
not isinstance(self.source_path, str) or not self.source_path
|
|
261
|
+
):
|
|
262
|
+
raise ConfigurationError(
|
|
263
|
+
"Table read source_path must be non-empty text or None.",
|
|
264
|
+
code="INVALID_TABLE_RESULT",
|
|
265
|
+
)
|
|
266
|
+
if (
|
|
267
|
+
isinstance(self.byte_count, bool)
|
|
268
|
+
or not isinstance(self.byte_count, int)
|
|
269
|
+
or self.byte_count < 0
|
|
270
|
+
):
|
|
271
|
+
raise ConfigurationError(
|
|
272
|
+
"Table read byte_count must be a non-negative integer.",
|
|
273
|
+
code="INVALID_TABLE_RESULT",
|
|
274
|
+
)
|
|
275
|
+
if not isinstance(self.parameters, FrozenDict):
|
|
276
|
+
raise ConfigurationError(
|
|
277
|
+
"Table read parameters must be an immutable mapping.",
|
|
278
|
+
code="INVALID_TABLE_RESULT",
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
@property
|
|
282
|
+
def row_count(self) -> int:
|
|
283
|
+
return len(self.rows)
|
|
284
|
+
|
|
285
|
+
def to_dict(self) -> dict[str, Any]:
|
|
286
|
+
return cast(dict[str, Any], to_json_compatible(self))
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _schema_payload(schema: TableSchema) -> dict[str, object]:
|
|
290
|
+
payload: dict[str, object] = {
|
|
291
|
+
"schema_version": schema.schema_version,
|
|
292
|
+
"columns": list(schema.columns),
|
|
293
|
+
}
|
|
294
|
+
if schema.column_types:
|
|
295
|
+
payload["column_types"] = dict(schema.column_types)
|
|
296
|
+
if schema.nullable != schema.columns:
|
|
297
|
+
payload["nullable"] = list(cast(tuple[str, ...], schema.nullable))
|
|
298
|
+
return payload
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _native_table_backend() -> dict[str, object]:
|
|
302
|
+
return {
|
|
303
|
+
"name": "python-stdlib",
|
|
304
|
+
"version": platform.python_version(),
|
|
305
|
+
"implementation": "native",
|
|
306
|
+
"license": "PSF-2.0",
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _parquet_backend(status: ParquetBackendStatus, *, compression: object) -> dict[str, object]:
|
|
311
|
+
return {
|
|
312
|
+
"name": status.distribution,
|
|
313
|
+
"version": status.version,
|
|
314
|
+
"implementation": status.implementation,
|
|
315
|
+
"license": "Apache-2.0",
|
|
316
|
+
"compression": compression,
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
class _BoundedTableTextWriter:
|
|
321
|
+
"""Count UTF-8 bytes before forwarding table text writes."""
|
|
322
|
+
|
|
323
|
+
def __init__(self, raw: TextIO, *, limit: int) -> None:
|
|
324
|
+
self._raw = raw
|
|
325
|
+
self._limit = limit
|
|
326
|
+
self._count = 0
|
|
327
|
+
|
|
328
|
+
def write(self, value: str) -> int:
|
|
329
|
+
size = len(value.encode("utf-8"))
|
|
330
|
+
if self._count + size > self._limit:
|
|
331
|
+
raise InputFormatError(
|
|
332
|
+
"Table export exceeds max_output_bytes.",
|
|
333
|
+
code="TABLE_OUTPUT_LIMIT_EXCEEDED",
|
|
334
|
+
context={"max_output_bytes": self._limit},
|
|
335
|
+
)
|
|
336
|
+
written = self._raw.write(value)
|
|
337
|
+
self._count += size
|
|
338
|
+
return written
|
|
339
|
+
|
|
340
|
+
def flush(self) -> None:
|
|
341
|
+
self._raw.flush()
|
|
342
|
+
|
|
343
|
+
def __getattr__(self, name: str) -> object:
|
|
344
|
+
return getattr(self._raw, name)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
class _BoundedTableBinaryWriter:
|
|
348
|
+
"""Count physical bytes before forwarding Parquet writes."""
|
|
349
|
+
|
|
350
|
+
def __init__(self, raw: BinaryIO, *, limit: int) -> None:
|
|
351
|
+
self._raw = raw
|
|
352
|
+
self._limit = limit
|
|
353
|
+
self._count = 0
|
|
354
|
+
|
|
355
|
+
def write(self, value: bytes | bytearray | memoryview) -> int:
|
|
356
|
+
size = len(value)
|
|
357
|
+
if self._count + size > self._limit:
|
|
358
|
+
raise InputFormatError(
|
|
359
|
+
"Table export exceeds max_output_bytes.",
|
|
360
|
+
code="TABLE_OUTPUT_LIMIT_EXCEEDED",
|
|
361
|
+
context={"max_output_bytes": self._limit},
|
|
362
|
+
)
|
|
363
|
+
written = self._raw.write(value)
|
|
364
|
+
self._count += written
|
|
365
|
+
return written
|
|
366
|
+
|
|
367
|
+
def flush(self) -> None:
|
|
368
|
+
self._raw.flush()
|
|
369
|
+
|
|
370
|
+
def __getattr__(self, name: str) -> object:
|
|
371
|
+
return getattr(self._raw, name)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _iter_rows(
|
|
375
|
+
rows: Iterable[Mapping[str, object] | Sequence[object] | DictResult],
|
|
376
|
+
*,
|
|
377
|
+
schema: TableSchema,
|
|
378
|
+
max_rows: int,
|
|
379
|
+
max_cell_characters: int,
|
|
380
|
+
) -> Iterable[dict[str, TableScalar]]:
|
|
381
|
+
for row_index, row in enumerate(rows):
|
|
382
|
+
if row_index >= max_rows:
|
|
383
|
+
raise InputFormatError(
|
|
384
|
+
"Table export exceeds max_rows.",
|
|
385
|
+
code="TABLE_ROW_LIMIT_EXCEEDED",
|
|
386
|
+
context={"max_rows": max_rows},
|
|
387
|
+
)
|
|
388
|
+
if isinstance(row, DictResult):
|
|
389
|
+
raw: object = row.to_dict()
|
|
390
|
+
else:
|
|
391
|
+
raw = row
|
|
392
|
+
width = len(schema.columns)
|
|
393
|
+
if isinstance(raw, Mapping):
|
|
394
|
+
keys = tuple(islice(iter(raw), width + 1))
|
|
395
|
+
if (
|
|
396
|
+
len(keys) != width
|
|
397
|
+
or any(not isinstance(key, str) for key in keys)
|
|
398
|
+
or set(keys) != set(schema.columns)
|
|
399
|
+
):
|
|
400
|
+
raise InputFormatError(
|
|
401
|
+
"Mapping row keys must match the table schema exactly.",
|
|
402
|
+
code="TABLE_SCHEMA_MISMATCH",
|
|
403
|
+
context={"row_index": row_index},
|
|
404
|
+
)
|
|
405
|
+
values = tuple(raw[column] for column in schema.columns)
|
|
406
|
+
elif isinstance(raw, Sequence) and not isinstance(raw, (str, bytes)):
|
|
407
|
+
values = tuple(islice(iter(raw), width + 1))
|
|
408
|
+
if len(values) != width:
|
|
409
|
+
raise InputFormatError(
|
|
410
|
+
"Sequence row length must equal the table column count.",
|
|
411
|
+
code="TABLE_SCHEMA_MISMATCH",
|
|
412
|
+
context={"row_index": row_index},
|
|
413
|
+
)
|
|
414
|
+
else:
|
|
415
|
+
raise TypeError(
|
|
416
|
+
"Each table row must be a mapping, non-text sequence, or object with to_dict()."
|
|
417
|
+
)
|
|
418
|
+
resolved: dict[str, TableScalar] = {}
|
|
419
|
+
for column, value in zip(schema.columns, values, strict=True):
|
|
420
|
+
scalar = _validate_schema_scalar(
|
|
421
|
+
value, schema=schema, row_index=row_index, column=column
|
|
422
|
+
)
|
|
423
|
+
if isinstance(scalar, str) and len(scalar) > max_cell_characters:
|
|
424
|
+
raise InputFormatError(
|
|
425
|
+
"Table cell exceeds max_cell_characters.",
|
|
426
|
+
code="TABLE_CELL_LIMIT_EXCEEDED",
|
|
427
|
+
context={
|
|
428
|
+
"row_index": row_index,
|
|
429
|
+
"column": column,
|
|
430
|
+
"max_cell_characters": max_cell_characters,
|
|
431
|
+
},
|
|
432
|
+
)
|
|
433
|
+
resolved[column] = scalar
|
|
434
|
+
yield resolved
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _write_delimited(
|
|
438
|
+
handle: TextIO,
|
|
439
|
+
rows: Iterable[dict[str, TableScalar]],
|
|
440
|
+
*,
|
|
441
|
+
columns: tuple[str, ...],
|
|
442
|
+
delimiter: str,
|
|
443
|
+
null_value: str,
|
|
444
|
+
) -> int:
|
|
445
|
+
writer = csv.DictWriter(handle, fieldnames=columns, delimiter=delimiter, lineterminator="\n")
|
|
446
|
+
writer.writeheader()
|
|
447
|
+
count = 0
|
|
448
|
+
for row in rows:
|
|
449
|
+
values: dict[str, TableScalar] = {}
|
|
450
|
+
for column, value in row.items():
|
|
451
|
+
if isinstance(value, str) and value == null_value:
|
|
452
|
+
raise InputFormatError(
|
|
453
|
+
"A string table cell conflicts with null_value.",
|
|
454
|
+
code="TABLE_NULL_SENTINEL_COLLISION",
|
|
455
|
+
context={"row_index": count, "column": column, "null_value": null_value},
|
|
456
|
+
)
|
|
457
|
+
values[column] = null_value if value is None else value
|
|
458
|
+
writer.writerow(values)
|
|
459
|
+
count += 1
|
|
460
|
+
return count
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _write_json(
|
|
464
|
+
handle: TextIO,
|
|
465
|
+
rows: Iterable[dict[str, TableScalar]],
|
|
466
|
+
*,
|
|
467
|
+
schema: TableSchema,
|
|
468
|
+
) -> int:
|
|
469
|
+
handle.write(
|
|
470
|
+
json.dumps(
|
|
471
|
+
_schema_payload(schema),
|
|
472
|
+
ensure_ascii=False,
|
|
473
|
+
sort_keys=True,
|
|
474
|
+
separators=(",", ":"),
|
|
475
|
+
)[:-1]
|
|
476
|
+
+ ',"rows":['
|
|
477
|
+
)
|
|
478
|
+
count = 0
|
|
479
|
+
for row in rows:
|
|
480
|
+
if count:
|
|
481
|
+
handle.write(",")
|
|
482
|
+
handle.write(json.dumps(row, ensure_ascii=False, sort_keys=False, separators=(",", ":")))
|
|
483
|
+
count += 1
|
|
484
|
+
handle.write("]}\n")
|
|
485
|
+
return count
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _write_parquet(
|
|
489
|
+
rows: Iterable[dict[str, TableScalar]],
|
|
490
|
+
path: Path,
|
|
491
|
+
*,
|
|
492
|
+
schema: TableSchema,
|
|
493
|
+
overwrite: bool,
|
|
494
|
+
max_output_bytes: int,
|
|
495
|
+
compression: ParquetCompression,
|
|
496
|
+
) -> int:
|
|
497
|
+
require_parquet_backend()
|
|
498
|
+
try:
|
|
499
|
+
import pyarrow as pa # type: ignore[import-untyped]
|
|
500
|
+
import pyarrow.parquet as pq # type: ignore[import-untyped]
|
|
501
|
+
except (ImportError, AttributeError) as exc:
|
|
502
|
+
raise BackendExecutionError(
|
|
503
|
+
"PyArrow was detected but could not be imported.",
|
|
504
|
+
code="PARQUET_BACKEND_IMPORT_FAILED",
|
|
505
|
+
) from exc
|
|
506
|
+
materialized = list(rows)
|
|
507
|
+
if path.exists() and not overwrite:
|
|
508
|
+
raise FileExistsError(f"Refusing to overwrite existing output: {path}")
|
|
509
|
+
if not path.parent.exists():
|
|
510
|
+
raise FileNotFoundError(f"Output parent directory does not exist: {path.parent}")
|
|
511
|
+
descriptor, name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
|
|
512
|
+
os.close(descriptor)
|
|
513
|
+
temporary = Path(name)
|
|
514
|
+
try:
|
|
515
|
+
columns = {column: [row[column] for row in materialized] for column in schema.columns}
|
|
516
|
+
table = pa.table(columns)
|
|
517
|
+
metadata = dict(table.schema.metadata or {})
|
|
518
|
+
metadata[b"dnakit_schema_version"] = schema.schema_version.encode("utf-8")
|
|
519
|
+
metadata[b"dnakit_table_schema"] = json.dumps(
|
|
520
|
+
_schema_payload(schema),
|
|
521
|
+
ensure_ascii=False,
|
|
522
|
+
sort_keys=True,
|
|
523
|
+
separators=(",", ":"),
|
|
524
|
+
).encode("utf-8")
|
|
525
|
+
table = table.replace_schema_metadata(metadata)
|
|
526
|
+
with temporary.open("wb") as raw:
|
|
527
|
+
bounded = _BoundedTableBinaryWriter(raw, limit=max_output_bytes)
|
|
528
|
+
pq.write_table(
|
|
529
|
+
table,
|
|
530
|
+
bounded,
|
|
531
|
+
compression=None if compression == "none" else compression,
|
|
532
|
+
)
|
|
533
|
+
bounded.flush()
|
|
534
|
+
os.fsync(raw.fileno())
|
|
535
|
+
except InputFormatError:
|
|
536
|
+
temporary.unlink(missing_ok=True)
|
|
537
|
+
raise
|
|
538
|
+
except Exception as exc:
|
|
539
|
+
temporary.unlink(missing_ok=True)
|
|
540
|
+
raise BackendExecutionError(
|
|
541
|
+
"PyArrow failed to serialize the bounded table.",
|
|
542
|
+
code="PARQUET_BACKEND_EXECUTION_FAILED",
|
|
543
|
+
context={"reason": str(exc)},
|
|
544
|
+
) from exc
|
|
545
|
+
try:
|
|
546
|
+
if overwrite:
|
|
547
|
+
os.replace(temporary, path)
|
|
548
|
+
else:
|
|
549
|
+
os.link(temporary, path)
|
|
550
|
+
temporary.unlink()
|
|
551
|
+
except BaseException:
|
|
552
|
+
temporary.unlink(missing_ok=True)
|
|
553
|
+
raise
|
|
554
|
+
return len(materialized)
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def export_table(
|
|
558
|
+
rows: Iterable[Mapping[str, object] | Sequence[object] | DictResult],
|
|
559
|
+
target: str | os.PathLike[str],
|
|
560
|
+
*,
|
|
561
|
+
format: TableFormat,
|
|
562
|
+
schema: TableSchema,
|
|
563
|
+
overwrite: bool = False,
|
|
564
|
+
max_rows: int = 1_000_000,
|
|
565
|
+
max_columns: int = 10_000,
|
|
566
|
+
max_cell_characters: int = 10_000_000,
|
|
567
|
+
max_output_bytes: int = 1_000_000_000,
|
|
568
|
+
null_value: str = r"\N",
|
|
569
|
+
parquet_compression: ParquetCompression = "snappy",
|
|
570
|
+
) -> TableExportResult:
|
|
571
|
+
"""Export one bounded table without inferring or silently widening its schema."""
|
|
572
|
+
|
|
573
|
+
if format not in {"csv", "tsv", "json", "parquet"}:
|
|
574
|
+
raise ConfigurationError("Unknown table export format.", code="INVALID_TABLE_FORMAT")
|
|
575
|
+
if not isinstance(schema, TableSchema):
|
|
576
|
+
raise TypeError("schema must be a TableSchema.")
|
|
577
|
+
for name, value in (
|
|
578
|
+
("max_rows", max_rows),
|
|
579
|
+
("max_columns", max_columns),
|
|
580
|
+
("max_cell_characters", max_cell_characters),
|
|
581
|
+
("max_output_bytes", max_output_bytes),
|
|
582
|
+
):
|
|
583
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
|
584
|
+
raise ConfigurationError(
|
|
585
|
+
f"{name} must be positive.",
|
|
586
|
+
code="INVALID_TABLE_LIMIT",
|
|
587
|
+
context={"field": name},
|
|
588
|
+
)
|
|
589
|
+
if not isinstance(null_value, str) or not null_value:
|
|
590
|
+
raise ConfigurationError(
|
|
591
|
+
"null_value must be non-empty text.", code="INVALID_TABLE_NULL_VALUE"
|
|
592
|
+
)
|
|
593
|
+
if len(null_value) > max_cell_characters:
|
|
594
|
+
raise ConfigurationError(
|
|
595
|
+
"null_value exceeds max_cell_characters.", code="INVALID_TABLE_NULL_VALUE"
|
|
596
|
+
)
|
|
597
|
+
if not isinstance(parquet_compression, str) or parquet_compression not in (
|
|
598
|
+
"none",
|
|
599
|
+
"snappy",
|
|
600
|
+
"gzip",
|
|
601
|
+
"brotli",
|
|
602
|
+
"lz4",
|
|
603
|
+
"zstd",
|
|
604
|
+
):
|
|
605
|
+
raise ConfigurationError(
|
|
606
|
+
"parquet_compression is unsupported.", code="INVALID_PARQUET_COMPRESSION"
|
|
607
|
+
)
|
|
608
|
+
if len(schema.columns) > max_columns:
|
|
609
|
+
raise InputFormatError(
|
|
610
|
+
"Table schema exceeds max_columns.",
|
|
611
|
+
code="TABLE_COLUMN_LIMIT_EXCEEDED",
|
|
612
|
+
context={"max_columns": max_columns},
|
|
613
|
+
)
|
|
614
|
+
path = Path(target)
|
|
615
|
+
prepared = _iter_rows(
|
|
616
|
+
rows,
|
|
617
|
+
schema=schema,
|
|
618
|
+
max_rows=max_rows,
|
|
619
|
+
max_cell_characters=max_cell_characters,
|
|
620
|
+
)
|
|
621
|
+
if format == "parquet":
|
|
622
|
+
parquet_status = require_parquet_backend()
|
|
623
|
+
count = _write_parquet(
|
|
624
|
+
prepared,
|
|
625
|
+
path,
|
|
626
|
+
schema=schema,
|
|
627
|
+
overwrite=overwrite,
|
|
628
|
+
max_output_bytes=max_output_bytes,
|
|
629
|
+
compression=parquet_compression,
|
|
630
|
+
)
|
|
631
|
+
backend = _parquet_backend(parquet_status, compression=parquet_compression)
|
|
632
|
+
else:
|
|
633
|
+
|
|
634
|
+
def writer(handle: TextIO) -> int:
|
|
635
|
+
bounded = cast(TextIO, _BoundedTableTextWriter(handle, limit=max_output_bytes))
|
|
636
|
+
if format == "json":
|
|
637
|
+
return _write_json(bounded, prepared, schema=schema)
|
|
638
|
+
return _write_delimited(
|
|
639
|
+
bounded,
|
|
640
|
+
prepared,
|
|
641
|
+
columns=schema.columns,
|
|
642
|
+
delimiter="," if format == "csv" else "\t",
|
|
643
|
+
null_value=null_value,
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
count = write_text_path(path, writer, overwrite=overwrite, create_parents=False)
|
|
647
|
+
backend = _native_table_backend()
|
|
648
|
+
return TableExportResult(
|
|
649
|
+
format,
|
|
650
|
+
count,
|
|
651
|
+
schema.columns,
|
|
652
|
+
schema.schema_version,
|
|
653
|
+
str(path),
|
|
654
|
+
path.stat().st_size,
|
|
655
|
+
freeze_mapping(
|
|
656
|
+
{
|
|
657
|
+
"max_rows": max_rows,
|
|
658
|
+
"max_columns": max_columns,
|
|
659
|
+
"max_cell_characters": max_cell_characters,
|
|
660
|
+
"max_output_bytes": max_output_bytes,
|
|
661
|
+
"null_value": null_value,
|
|
662
|
+
"parquet_compression": parquet_compression,
|
|
663
|
+
"backend": backend,
|
|
664
|
+
}
|
|
665
|
+
),
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def _validate_table_limits(
|
|
670
|
+
*,
|
|
671
|
+
max_rows: int,
|
|
672
|
+
max_columns: int,
|
|
673
|
+
max_cell_characters: int,
|
|
674
|
+
max_file_bytes: int,
|
|
675
|
+
max_decoded_bytes: int,
|
|
676
|
+
) -> None:
|
|
677
|
+
for name, value in (
|
|
678
|
+
("max_rows", max_rows),
|
|
679
|
+
("max_columns", max_columns),
|
|
680
|
+
("max_cell_characters", max_cell_characters),
|
|
681
|
+
("max_file_bytes", max_file_bytes),
|
|
682
|
+
("max_decoded_bytes", max_decoded_bytes),
|
|
683
|
+
):
|
|
684
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
|
685
|
+
raise ConfigurationError(
|
|
686
|
+
f"{name} must be positive.",
|
|
687
|
+
code="INVALID_TABLE_LIMIT",
|
|
688
|
+
context={"field": name},
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
@contextmanager
|
|
693
|
+
def _open_table_text(
|
|
694
|
+
source: str | os.PathLike[str] | TextIO, *, max_file_bytes: int
|
|
695
|
+
) -> Iterator[tuple[TextIO, str | None]]:
|
|
696
|
+
if isinstance(source, (str, os.PathLike)):
|
|
697
|
+
path = Path(source)
|
|
698
|
+
try:
|
|
699
|
+
stat = path.stat()
|
|
700
|
+
if stat.st_size > max_file_bytes:
|
|
701
|
+
raise InputFormatError(
|
|
702
|
+
"Table input exceeds max_file_bytes.",
|
|
703
|
+
code="TABLE_FILE_LIMIT_EXCEEDED",
|
|
704
|
+
context={"max_file_bytes": max_file_bytes, "source": str(path)},
|
|
705
|
+
)
|
|
706
|
+
with path.open("rb") as probe:
|
|
707
|
+
if probe.read(2) == b"\x1f\x8b":
|
|
708
|
+
raise InputFormatError(
|
|
709
|
+
"Generic table reading does not accept gzip-compressed input.",
|
|
710
|
+
code="COMPRESSED_TABLE_UNSUPPORTED",
|
|
711
|
+
)
|
|
712
|
+
with path.open("r", encoding="utf-8", newline="") as handle:
|
|
713
|
+
yield handle, str(path)
|
|
714
|
+
except InputFormatError:
|
|
715
|
+
raise
|
|
716
|
+
except (OSError, UnicodeError) as exc:
|
|
717
|
+
raise InputFormatError(
|
|
718
|
+
"Could not open or decode table input.",
|
|
719
|
+
code="TABLE_READ_FAILED",
|
|
720
|
+
context={"source": str(path), "reason": str(exc)},
|
|
721
|
+
) from exc
|
|
722
|
+
return
|
|
723
|
+
if not hasattr(source, "read"):
|
|
724
|
+
raise TypeError("source must be a path or readable text stream.")
|
|
725
|
+
stream = source
|
|
726
|
+
stream_probe = stream.read(0)
|
|
727
|
+
if not isinstance(stream_probe, str):
|
|
728
|
+
raise TypeError("generic CSV, TSV, and JSON readers require a decoded text stream.")
|
|
729
|
+
yield stream, None
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def _read_bounded_text(
|
|
733
|
+
handle: TextIO, *, max_file_bytes: int, limit_error_code: str = "TABLE_FILE_LIMIT_EXCEEDED"
|
|
734
|
+
) -> tuple[str, int]:
|
|
735
|
+
chunks: list[str] = []
|
|
736
|
+
byte_count = 0
|
|
737
|
+
while True:
|
|
738
|
+
chunk = handle.read(min(65_536, max_file_bytes - byte_count + 1))
|
|
739
|
+
if not chunk:
|
|
740
|
+
break
|
|
741
|
+
byte_count += len(chunk.encode("utf-8"))
|
|
742
|
+
if byte_count > max_file_bytes:
|
|
743
|
+
raise InputFormatError(
|
|
744
|
+
"Table input exceeds max_file_bytes.",
|
|
745
|
+
code=limit_error_code,
|
|
746
|
+
context={"max_file_bytes": max_file_bytes},
|
|
747
|
+
)
|
|
748
|
+
chunks.append(chunk)
|
|
749
|
+
return "".join(chunks), byte_count
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def _parse_delimited_scalar(
|
|
753
|
+
raw: str,
|
|
754
|
+
*,
|
|
755
|
+
schema: TableSchema,
|
|
756
|
+
column: str,
|
|
757
|
+
row_index: int,
|
|
758
|
+
missing_values: tuple[str, ...],
|
|
759
|
+
) -> TableScalar:
|
|
760
|
+
if raw in missing_values:
|
|
761
|
+
return _validate_schema_scalar(None, schema=schema, row_index=row_index, column=column)
|
|
762
|
+
expected = schema.column_type(column)
|
|
763
|
+
try:
|
|
764
|
+
if expected in {"any", "string"}:
|
|
765
|
+
value: object = raw
|
|
766
|
+
elif expected == "integer":
|
|
767
|
+
if re.fullmatch(r"[+-]?\d+", raw) is None:
|
|
768
|
+
raise ValueError
|
|
769
|
+
value = int(raw)
|
|
770
|
+
elif expected == "number":
|
|
771
|
+
value = float(raw)
|
|
772
|
+
if not math.isfinite(value):
|
|
773
|
+
raise ValueError
|
|
774
|
+
else:
|
|
775
|
+
lowered = raw.lower()
|
|
776
|
+
if lowered not in {"true", "false"}:
|
|
777
|
+
raise ValueError
|
|
778
|
+
value = lowered == "true"
|
|
779
|
+
except (OverflowError, ValueError) as exc:
|
|
780
|
+
raise InputFormatError(
|
|
781
|
+
"Delimited table cell cannot be decoded as its declared type.",
|
|
782
|
+
code="TABLE_COLUMN_TYPE_MISMATCH",
|
|
783
|
+
context={"row_index": row_index, "column": column, "expected": expected},
|
|
784
|
+
) from exc
|
|
785
|
+
return _validate_schema_scalar(value, schema=schema, row_index=row_index, column=column)
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def _validated_read_row(
|
|
789
|
+
row: Mapping[str, object],
|
|
790
|
+
*,
|
|
791
|
+
schema: TableSchema,
|
|
792
|
+
row_index: int,
|
|
793
|
+
max_cell_characters: int,
|
|
794
|
+
) -> FrozenDict:
|
|
795
|
+
if set(row) != set(schema.columns) or any(not isinstance(key, str) for key in row):
|
|
796
|
+
raise InputFormatError(
|
|
797
|
+
"Table row keys must match the declared schema exactly.",
|
|
798
|
+
code="TABLE_SCHEMA_MISMATCH",
|
|
799
|
+
context={"row_index": row_index},
|
|
800
|
+
)
|
|
801
|
+
resolved: dict[str, TableScalar] = {}
|
|
802
|
+
for column in schema.columns:
|
|
803
|
+
value = _validate_schema_scalar(
|
|
804
|
+
row[column], schema=schema, row_index=row_index, column=column
|
|
805
|
+
)
|
|
806
|
+
if isinstance(value, str) and len(value) > max_cell_characters:
|
|
807
|
+
raise InputFormatError(
|
|
808
|
+
"Table cell exceeds max_cell_characters.",
|
|
809
|
+
code="TABLE_CELL_LIMIT_EXCEEDED",
|
|
810
|
+
context={"row_index": row_index, "column": column},
|
|
811
|
+
)
|
|
812
|
+
resolved[column] = value
|
|
813
|
+
return freeze_mapping(resolved)
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def _read_delimited_table(
|
|
817
|
+
text: str,
|
|
818
|
+
*,
|
|
819
|
+
delimiter: str,
|
|
820
|
+
schema: TableSchema,
|
|
821
|
+
missing_values: tuple[str, ...],
|
|
822
|
+
max_rows: int,
|
|
823
|
+
max_cell_characters: int,
|
|
824
|
+
) -> tuple[FrozenDict, ...]:
|
|
825
|
+
previous_limit = csv.field_size_limit()
|
|
826
|
+
rows: list[FrozenDict] = []
|
|
827
|
+
try:
|
|
828
|
+
csv.field_size_limit(max_cell_characters)
|
|
829
|
+
reader = csv.reader(io.StringIO(text), delimiter=delimiter, strict=True)
|
|
830
|
+
try:
|
|
831
|
+
header = tuple(next(reader))
|
|
832
|
+
except StopIteration as exc:
|
|
833
|
+
raise InputFormatError("Table input is empty.", code="INVALID_TABLE_HEADER") from exc
|
|
834
|
+
if header != schema.columns:
|
|
835
|
+
raise InputFormatError(
|
|
836
|
+
"Delimited table header must match the declared schema exactly.",
|
|
837
|
+
code="TABLE_SCHEMA_MISMATCH",
|
|
838
|
+
context={"actual_columns": header, "expected_columns": schema.columns},
|
|
839
|
+
)
|
|
840
|
+
for row_index, values in enumerate(reader):
|
|
841
|
+
if row_index >= max_rows:
|
|
842
|
+
raise InputFormatError(
|
|
843
|
+
"Table input exceeds max_rows.",
|
|
844
|
+
code="TABLE_ROW_LIMIT_EXCEEDED",
|
|
845
|
+
context={"max_rows": max_rows},
|
|
846
|
+
)
|
|
847
|
+
if len(values) != len(schema.columns):
|
|
848
|
+
raise InputFormatError(
|
|
849
|
+
"Delimited table row field count does not match its header.",
|
|
850
|
+
code="TABLE_SCHEMA_MISMATCH",
|
|
851
|
+
context={"row_index": row_index},
|
|
852
|
+
)
|
|
853
|
+
resolved = {
|
|
854
|
+
column: _parse_delimited_scalar(
|
|
855
|
+
raw,
|
|
856
|
+
schema=schema,
|
|
857
|
+
column=column,
|
|
858
|
+
row_index=row_index,
|
|
859
|
+
missing_values=missing_values,
|
|
860
|
+
)
|
|
861
|
+
for column, raw in zip(schema.columns, values, strict=True)
|
|
862
|
+
}
|
|
863
|
+
rows.append(
|
|
864
|
+
_validated_read_row(
|
|
865
|
+
resolved,
|
|
866
|
+
schema=schema,
|
|
867
|
+
row_index=row_index,
|
|
868
|
+
max_cell_characters=max_cell_characters,
|
|
869
|
+
)
|
|
870
|
+
)
|
|
871
|
+
except csv.Error as exc:
|
|
872
|
+
raise InputFormatError(
|
|
873
|
+
"Delimited table is malformed or exceeds max_cell_characters.",
|
|
874
|
+
code="INVALID_TABLE_DELIMITED",
|
|
875
|
+
context={"reason": str(exc)},
|
|
876
|
+
) from exc
|
|
877
|
+
finally:
|
|
878
|
+
csv.field_size_limit(previous_limit)
|
|
879
|
+
return tuple(rows)
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def _read_json_table(
|
|
883
|
+
text: str,
|
|
884
|
+
*,
|
|
885
|
+
schema: TableSchema,
|
|
886
|
+
max_rows: int,
|
|
887
|
+
max_cell_characters: int,
|
|
888
|
+
) -> tuple[FrozenDict, ...]:
|
|
889
|
+
def strict_object(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
|
890
|
+
result: dict[str, object] = {}
|
|
891
|
+
for key, value in pairs:
|
|
892
|
+
if key in result:
|
|
893
|
+
raise ValueError(f"duplicate JSON object key: {key}")
|
|
894
|
+
result[key] = value
|
|
895
|
+
return result
|
|
896
|
+
|
|
897
|
+
def reject_constant(value: str) -> object:
|
|
898
|
+
raise ValueError(f"non-standard JSON constant: {value}")
|
|
899
|
+
|
|
900
|
+
try:
|
|
901
|
+
payload = json.loads(
|
|
902
|
+
text,
|
|
903
|
+
object_pairs_hook=strict_object,
|
|
904
|
+
parse_constant=reject_constant,
|
|
905
|
+
)
|
|
906
|
+
except (json.JSONDecodeError, RecursionError, ValueError) as exc:
|
|
907
|
+
context: dict[str, object] = {}
|
|
908
|
+
if isinstance(exc, json.JSONDecodeError):
|
|
909
|
+
context = {"line_number": exc.lineno, "column_number": exc.colno}
|
|
910
|
+
raise InputFormatError(
|
|
911
|
+
"JSON table input is invalid.",
|
|
912
|
+
code="INVALID_TABLE_JSON",
|
|
913
|
+
context=context,
|
|
914
|
+
) from exc
|
|
915
|
+
if not isinstance(payload, Mapping) or set(payload) - {
|
|
916
|
+
"schema_version",
|
|
917
|
+
"columns",
|
|
918
|
+
"column_types",
|
|
919
|
+
"nullable",
|
|
920
|
+
"rows",
|
|
921
|
+
}:
|
|
922
|
+
raise InputFormatError("JSON table root/schema is invalid.", code="INVALID_TABLE_JSON")
|
|
923
|
+
metadata = {key: value for key, value in payload.items() if key != "rows"}
|
|
924
|
+
if metadata != _schema_payload(schema):
|
|
925
|
+
raise InputFormatError(
|
|
926
|
+
"JSON table metadata does not match the declared schema.",
|
|
927
|
+
code="TABLE_SCHEMA_MISMATCH",
|
|
928
|
+
)
|
|
929
|
+
raw_rows = payload.get("rows")
|
|
930
|
+
if not isinstance(raw_rows, list):
|
|
931
|
+
raise InputFormatError("JSON table rows must be an array.", code="INVALID_TABLE_JSON")
|
|
932
|
+
if len(raw_rows) > max_rows:
|
|
933
|
+
raise InputFormatError(
|
|
934
|
+
"Table input exceeds max_rows.",
|
|
935
|
+
code="TABLE_ROW_LIMIT_EXCEEDED",
|
|
936
|
+
context={"max_rows": max_rows},
|
|
937
|
+
)
|
|
938
|
+
rows: list[FrozenDict] = []
|
|
939
|
+
for row_index, raw in enumerate(raw_rows):
|
|
940
|
+
if not isinstance(raw, Mapping):
|
|
941
|
+
raise InputFormatError(
|
|
942
|
+
"Each table row must be an object.",
|
|
943
|
+
code="INVALID_TABLE_ROW",
|
|
944
|
+
context={"row_index": row_index},
|
|
945
|
+
)
|
|
946
|
+
rows.append(
|
|
947
|
+
_validated_read_row(
|
|
948
|
+
cast(Mapping[str, object], raw),
|
|
949
|
+
schema=schema,
|
|
950
|
+
row_index=row_index,
|
|
951
|
+
max_cell_characters=max_cell_characters,
|
|
952
|
+
)
|
|
953
|
+
)
|
|
954
|
+
return tuple(rows)
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
def _read_parquet_table(
|
|
958
|
+
source: str | os.PathLike[str],
|
|
959
|
+
*,
|
|
960
|
+
schema: TableSchema,
|
|
961
|
+
max_rows: int,
|
|
962
|
+
max_cell_characters: int,
|
|
963
|
+
max_file_bytes: int,
|
|
964
|
+
max_decoded_bytes: int,
|
|
965
|
+
) -> tuple[tuple[FrozenDict, ...], int, str, dict[str, object]]:
|
|
966
|
+
status = require_parquet_backend()
|
|
967
|
+
path = Path(source)
|
|
968
|
+
try:
|
|
969
|
+
stat = path.stat()
|
|
970
|
+
if stat.st_size > max_file_bytes:
|
|
971
|
+
raise InputFormatError(
|
|
972
|
+
"Table input exceeds max_file_bytes.",
|
|
973
|
+
code="TABLE_FILE_LIMIT_EXCEEDED",
|
|
974
|
+
context={"max_file_bytes": max_file_bytes},
|
|
975
|
+
)
|
|
976
|
+
with path.open("rb") as probe:
|
|
977
|
+
if probe.read(2) == b"\x1f\x8b":
|
|
978
|
+
raise InputFormatError(
|
|
979
|
+
"Generic Parquet reading does not accept gzip wrappers.",
|
|
980
|
+
code="COMPRESSED_TABLE_UNSUPPORTED",
|
|
981
|
+
)
|
|
982
|
+
import pyarrow.parquet as pq
|
|
983
|
+
|
|
984
|
+
parquet = pq.ParquetFile(path)
|
|
985
|
+
if parquet.metadata.num_rows > max_rows:
|
|
986
|
+
raise InputFormatError(
|
|
987
|
+
"Table input exceeds max_rows.",
|
|
988
|
+
code="TABLE_ROW_LIMIT_EXCEEDED",
|
|
989
|
+
context={"max_rows": max_rows},
|
|
990
|
+
)
|
|
991
|
+
decoded_size = sum(
|
|
992
|
+
parquet.metadata.row_group(row_group).column(column).total_uncompressed_size
|
|
993
|
+
for row_group in range(parquet.metadata.num_row_groups)
|
|
994
|
+
for column in range(parquet.metadata.num_columns)
|
|
995
|
+
)
|
|
996
|
+
if decoded_size > max_decoded_bytes:
|
|
997
|
+
raise InputFormatError(
|
|
998
|
+
"Parquet table exceeds max_decoded_bytes before materialization.",
|
|
999
|
+
code="TABLE_DECODED_LIMIT_EXCEEDED",
|
|
1000
|
+
context={
|
|
1001
|
+
"decoded_bytes": decoded_size,
|
|
1002
|
+
"max_decoded_bytes": max_decoded_bytes,
|
|
1003
|
+
},
|
|
1004
|
+
)
|
|
1005
|
+
if tuple(parquet.schema_arrow.names) != schema.columns:
|
|
1006
|
+
raise InputFormatError(
|
|
1007
|
+
"Parquet columns must match the declared schema exactly.",
|
|
1008
|
+
code="TABLE_SCHEMA_MISMATCH",
|
|
1009
|
+
)
|
|
1010
|
+
metadata = parquet.schema_arrow.metadata or {}
|
|
1011
|
+
stored_version = metadata.get(b"dnakit_schema_version")
|
|
1012
|
+
if stored_version is not None and stored_version.decode("utf-8") != schema.schema_version:
|
|
1013
|
+
raise InputFormatError(
|
|
1014
|
+
"Parquet schema version does not match the declared schema.",
|
|
1015
|
+
code="TABLE_SCHEMA_MISMATCH",
|
|
1016
|
+
)
|
|
1017
|
+
stored_schema = metadata.get(b"dnakit_table_schema")
|
|
1018
|
+
if stored_schema is not None:
|
|
1019
|
+
try:
|
|
1020
|
+
embedded_schema = json.loads(stored_schema.decode("utf-8"))
|
|
1021
|
+
except (UnicodeError, json.JSONDecodeError, RecursionError) as exc:
|
|
1022
|
+
raise InputFormatError(
|
|
1023
|
+
"Parquet DNAKit schema metadata is invalid.",
|
|
1024
|
+
code="INVALID_TABLE_SCHEMA_METADATA",
|
|
1025
|
+
) from exc
|
|
1026
|
+
if embedded_schema != _schema_payload(schema):
|
|
1027
|
+
raise InputFormatError(
|
|
1028
|
+
"Parquet metadata does not match the declared schema.",
|
|
1029
|
+
code="TABLE_SCHEMA_MISMATCH",
|
|
1030
|
+
)
|
|
1031
|
+
rows: list[FrozenDict] = []
|
|
1032
|
+
for batch in parquet.iter_batches(batch_size=min(max_rows, 4_096)):
|
|
1033
|
+
for raw in batch.to_pylist():
|
|
1034
|
+
row_index = len(rows)
|
|
1035
|
+
rows.append(
|
|
1036
|
+
_validated_read_row(
|
|
1037
|
+
cast(Mapping[str, object], raw),
|
|
1038
|
+
schema=schema,
|
|
1039
|
+
row_index=row_index,
|
|
1040
|
+
max_cell_characters=max_cell_characters,
|
|
1041
|
+
)
|
|
1042
|
+
)
|
|
1043
|
+
compressions = sorted(
|
|
1044
|
+
{
|
|
1045
|
+
parquet.metadata.row_group(row_group).column(column).compression.lower()
|
|
1046
|
+
for row_group in range(parquet.metadata.num_row_groups)
|
|
1047
|
+
for column in range(parquet.metadata.num_columns)
|
|
1048
|
+
}
|
|
1049
|
+
)
|
|
1050
|
+
backend = _parquet_backend(status, compression=compressions)
|
|
1051
|
+
return tuple(rows), stat.st_size, str(path), backend
|
|
1052
|
+
except InputFormatError:
|
|
1053
|
+
raise
|
|
1054
|
+
except (OSError, UnicodeError) as exc:
|
|
1055
|
+
raise InputFormatError(
|
|
1056
|
+
"Could not open Parquet table input.",
|
|
1057
|
+
code="TABLE_READ_FAILED",
|
|
1058
|
+
context={"source": str(path), "reason": str(exc)},
|
|
1059
|
+
) from exc
|
|
1060
|
+
except DNAKitError:
|
|
1061
|
+
raise
|
|
1062
|
+
except Exception as exc:
|
|
1063
|
+
raise BackendExecutionError(
|
|
1064
|
+
"PyArrow failed to read the bounded table.",
|
|
1065
|
+
code="PARQUET_BACKEND_EXECUTION_FAILED",
|
|
1066
|
+
context={"reason": str(exc)},
|
|
1067
|
+
) from exc
|
|
1068
|
+
|
|
1069
|
+
|
|
1070
|
+
def read_table(
|
|
1071
|
+
source: str | os.PathLike[str] | TextIO,
|
|
1072
|
+
*,
|
|
1073
|
+
format: TableFormat,
|
|
1074
|
+
schema: TableSchema,
|
|
1075
|
+
missing_values: Sequence[str] = (r"\N",),
|
|
1076
|
+
max_rows: int = 1_000_000,
|
|
1077
|
+
max_columns: int = 10_000,
|
|
1078
|
+
max_cell_characters: int = 10_000_000,
|
|
1079
|
+
max_file_bytes: int = 1_000_000_000,
|
|
1080
|
+
max_decoded_bytes: int = 1_000_000_000,
|
|
1081
|
+
) -> TableReadResult:
|
|
1082
|
+
"""Strictly read one bounded two-dimensional table with an explicit schema."""
|
|
1083
|
+
|
|
1084
|
+
if format not in {"csv", "tsv", "json", "parquet"}:
|
|
1085
|
+
raise ConfigurationError("Unknown table read format.", code="INVALID_TABLE_FORMAT")
|
|
1086
|
+
if not isinstance(schema, TableSchema):
|
|
1087
|
+
raise TypeError("schema must be a TableSchema.")
|
|
1088
|
+
_validate_table_limits(
|
|
1089
|
+
max_rows=max_rows,
|
|
1090
|
+
max_columns=max_columns,
|
|
1091
|
+
max_cell_characters=max_cell_characters,
|
|
1092
|
+
max_file_bytes=max_file_bytes,
|
|
1093
|
+
max_decoded_bytes=max_decoded_bytes,
|
|
1094
|
+
)
|
|
1095
|
+
if len(schema.columns) > max_columns:
|
|
1096
|
+
raise InputFormatError(
|
|
1097
|
+
"Table schema exceeds max_columns.",
|
|
1098
|
+
code="TABLE_COLUMN_LIMIT_EXCEEDED",
|
|
1099
|
+
context={"max_columns": max_columns},
|
|
1100
|
+
)
|
|
1101
|
+
resolved_missing = tuple(missing_values)
|
|
1102
|
+
if any(not isinstance(value, str) for value in resolved_missing) or len(
|
|
1103
|
+
set(resolved_missing)
|
|
1104
|
+
) != len(resolved_missing):
|
|
1105
|
+
raise ConfigurationError(
|
|
1106
|
+
"missing_values must contain unique strings.", code="INVALID_TABLE_MISSING_VALUES"
|
|
1107
|
+
)
|
|
1108
|
+
source_path: str | None
|
|
1109
|
+
if format == "parquet":
|
|
1110
|
+
if not isinstance(source, (str, os.PathLike)):
|
|
1111
|
+
raise TypeError("Parquet table reading requires a local filesystem path.")
|
|
1112
|
+
rows, byte_count, source_path, backend = _read_parquet_table(
|
|
1113
|
+
source,
|
|
1114
|
+
schema=schema,
|
|
1115
|
+
max_rows=max_rows,
|
|
1116
|
+
max_cell_characters=max_cell_characters,
|
|
1117
|
+
max_file_bytes=max_file_bytes,
|
|
1118
|
+
max_decoded_bytes=max_decoded_bytes,
|
|
1119
|
+
)
|
|
1120
|
+
else:
|
|
1121
|
+
with _open_table_text(source, max_file_bytes=max_file_bytes) as (handle, source_path):
|
|
1122
|
+
text_value, byte_count = _read_bounded_text(
|
|
1123
|
+
handle,
|
|
1124
|
+
max_file_bytes=min(max_file_bytes, max_decoded_bytes),
|
|
1125
|
+
limit_error_code=(
|
|
1126
|
+
"TABLE_DECODED_LIMIT_EXCEEDED"
|
|
1127
|
+
if max_decoded_bytes < max_file_bytes
|
|
1128
|
+
else "TABLE_FILE_LIMIT_EXCEEDED"
|
|
1129
|
+
),
|
|
1130
|
+
)
|
|
1131
|
+
if format == "json":
|
|
1132
|
+
rows = _read_json_table(
|
|
1133
|
+
text_value,
|
|
1134
|
+
schema=schema,
|
|
1135
|
+
max_rows=max_rows,
|
|
1136
|
+
max_cell_characters=max_cell_characters,
|
|
1137
|
+
)
|
|
1138
|
+
else:
|
|
1139
|
+
rows = _read_delimited_table(
|
|
1140
|
+
text_value,
|
|
1141
|
+
delimiter="," if format == "csv" else "\t",
|
|
1142
|
+
schema=schema,
|
|
1143
|
+
missing_values=resolved_missing,
|
|
1144
|
+
max_rows=max_rows,
|
|
1145
|
+
max_cell_characters=max_cell_characters,
|
|
1146
|
+
)
|
|
1147
|
+
backend = _native_table_backend()
|
|
1148
|
+
return TableReadResult(
|
|
1149
|
+
format,
|
|
1150
|
+
rows,
|
|
1151
|
+
schema,
|
|
1152
|
+
source_path,
|
|
1153
|
+
byte_count,
|
|
1154
|
+
freeze_mapping(
|
|
1155
|
+
{
|
|
1156
|
+
"missing_values": resolved_missing,
|
|
1157
|
+
"max_rows": max_rows,
|
|
1158
|
+
"max_columns": max_columns,
|
|
1159
|
+
"max_cell_characters": max_cell_characters,
|
|
1160
|
+
"max_file_bytes": max_file_bytes,
|
|
1161
|
+
"max_decoded_bytes": max_decoded_bytes,
|
|
1162
|
+
"backend": backend,
|
|
1163
|
+
}
|
|
1164
|
+
),
|
|
1165
|
+
)
|
|
1166
|
+
|
|
1167
|
+
|
|
1168
|
+
def export_result(
|
|
1169
|
+
result: DictResult,
|
|
1170
|
+
target: str | os.PathLike[str],
|
|
1171
|
+
*,
|
|
1172
|
+
format: TableFormat = "json",
|
|
1173
|
+
overwrite: bool = False,
|
|
1174
|
+
) -> TableExportResult:
|
|
1175
|
+
"""Export one flat result object's scalar fields as a one-row table."""
|
|
1176
|
+
|
|
1177
|
+
if not isinstance(result, DictResult):
|
|
1178
|
+
raise TypeError("result must provide to_dict().")
|
|
1179
|
+
raw_row = result.to_dict()
|
|
1180
|
+
if not isinstance(raw_row, Mapping) or not raw_row:
|
|
1181
|
+
raise InputFormatError(
|
|
1182
|
+
"Result to_dict() must return one non-empty flat mapping.",
|
|
1183
|
+
code="INVALID_RESULT_EXPORT",
|
|
1184
|
+
)
|
|
1185
|
+
raw_items = tuple(islice(raw_row.items(), _MAX_TABLE_SCHEMA_COLUMNS + 1))
|
|
1186
|
+
if len(raw_items) > _MAX_TABLE_SCHEMA_COLUMNS:
|
|
1187
|
+
raise InputFormatError(
|
|
1188
|
+
"Result to_dict() exceeds the hard column limit.",
|
|
1189
|
+
code="INVALID_RESULT_EXPORT",
|
|
1190
|
+
context={"max_columns": _MAX_TABLE_SCHEMA_COLUMNS},
|
|
1191
|
+
)
|
|
1192
|
+
row: dict[str, object] = {}
|
|
1193
|
+
for key, value in raw_items:
|
|
1194
|
+
if not isinstance(key, str):
|
|
1195
|
+
raise InputFormatError(
|
|
1196
|
+
"Result to_dict() keys must be strings.", code="INVALID_RESULT_EXPORT"
|
|
1197
|
+
)
|
|
1198
|
+
if value is None or isinstance(value, (bool, int, float, str)):
|
|
1199
|
+
row[key] = value
|
|
1200
|
+
else:
|
|
1201
|
+
try:
|
|
1202
|
+
row[key] = json.dumps(
|
|
1203
|
+
to_json_compatible(value),
|
|
1204
|
+
ensure_ascii=False,
|
|
1205
|
+
sort_keys=True,
|
|
1206
|
+
separators=(",", ":"),
|
|
1207
|
+
allow_nan=False,
|
|
1208
|
+
)
|
|
1209
|
+
except (DNAKitError, RecursionError, TypeError, ValueError) as exc:
|
|
1210
|
+
raise InputFormatError(
|
|
1211
|
+
"Result fields must be JSON-compatible.",
|
|
1212
|
+
code="INVALID_RESULT_EXPORT",
|
|
1213
|
+
context={"field": key},
|
|
1214
|
+
) from exc
|
|
1215
|
+
columns = tuple(row)
|
|
1216
|
+
return export_table(
|
|
1217
|
+
(row,),
|
|
1218
|
+
target,
|
|
1219
|
+
format=format,
|
|
1220
|
+
schema=TableSchema(columns, schema_version="dnakit.result-table.v1"),
|
|
1221
|
+
overwrite=overwrite,
|
|
1222
|
+
)
|
|
1223
|
+
|
|
1224
|
+
|
|
1225
|
+
__all__ = [
|
|
1226
|
+
"DictResult",
|
|
1227
|
+
"ParquetCompression",
|
|
1228
|
+
"TableColumnType",
|
|
1229
|
+
"TableExportResult",
|
|
1230
|
+
"TableFormat",
|
|
1231
|
+
"TableReadResult",
|
|
1232
|
+
"TableScalar",
|
|
1233
|
+
"TableSchema",
|
|
1234
|
+
"export_result",
|
|
1235
|
+
"export_table",
|
|
1236
|
+
"read_table",
|
|
1237
|
+
]
|