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.
Files changed (210) hide show
  1. dnakit/__init__.py +108 -0
  2. dnakit/_version.py +5 -0
  3. dnakit/alignment/__init__.py +6 -0
  4. dnakit/alignment/pairwise.py +512 -0
  5. dnakit/alignment/results.py +188 -0
  6. dnakit/backends/__init__.py +80 -0
  7. dnakit/backends/external.py +517 -0
  8. dnakit/backends/registry.py +207 -0
  9. dnakit/batch.py +452 -0
  10. dnakit/cache/__init__.py +5 -0
  11. dnakit/cache/store.py +424 -0
  12. dnakit/chunking.py +920 -0
  13. dnakit/cli/__init__.py +5 -0
  14. dnakit/cli/__main__.py +6 -0
  15. dnakit/cli/app.py +814 -0
  16. dnakit/cli/workflow.py +94 -0
  17. dnakit/config/__init__.py +5 -0
  18. dnakit/config/loader.py +381 -0
  19. dnakit/core/__init__.py +80 -0
  20. dnakit/core/_json.py +206 -0
  21. dnakit/core/backend_info.py +70 -0
  22. dnakit/core/collection.py +163 -0
  23. dnakit/core/coordinates.py +295 -0
  24. dnakit/core/enums.py +95 -0
  25. dnakit/core/facade.py +719 -0
  26. dnakit/core/feature.py +90 -0
  27. dnakit/core/gap.py +82 -0
  28. dnakit/core/issues.py +62 -0
  29. dnakit/core/provenance.py +305 -0
  30. dnakit/core/record.py +161 -0
  31. dnakit/core/results.py +134 -0
  32. dnakit/core/sequence.py +327 -0
  33. dnakit/datasets/__init__.py +100 -0
  34. dnakit/datasets/_advanced_shared.py +198 -0
  35. dnakit/datasets/_metadata.py +33 -0
  36. dnakit/datasets/advanced_deduplicate.py +183 -0
  37. dnakit/datasets/advanced_split.py +302 -0
  38. dnakit/datasets/clustering.py +286 -0
  39. dnakit/datasets/config.py +531 -0
  40. dnakit/datasets/deduplicate.py +303 -0
  41. dnakit/datasets/evaluation.py +244 -0
  42. dnakit/datasets/filter.py +135 -0
  43. dnakit/datasets/neural_clustering.py +221 -0
  44. dnakit/datasets/results.py +520 -0
  45. dnakit/datasets/split.py +450 -0
  46. dnakit/descriptors/__init__.py +102 -0
  47. dnakit/descriptors/_dinucleotide.py +345 -0
  48. dnakit/descriptors/_shared.py +180 -0
  49. dnakit/descriptors/all.py +917 -0
  50. dnakit/descriptors/basic.py +193 -0
  51. dnakit/descriptors/codon.py +195 -0
  52. dnakit/descriptors/complexity.py +201 -0
  53. dnakit/descriptors/entropy.py +101 -0
  54. dnakit/descriptors/homopolymer.py +105 -0
  55. dnakit/descriptors/kmer.py +88 -0
  56. dnakit/descriptors/results.py +286 -0
  57. dnakit/descriptors/schema.py +414 -0
  58. dnakit/descriptors/window.py +203 -0
  59. dnakit/download/__init__.py +54 -0
  60. dnakit/download/catalogs.py +166 -0
  61. dnakit/download/ena.py +131 -0
  62. dnakit/download/encode.py +103 -0
  63. dnakit/download/files.py +469 -0
  64. dnakit/download/indexes.py +233 -0
  65. dnakit/download/metadata.py +164 -0
  66. dnakit/download/models.py +227 -0
  67. dnakit/download/ncbi.py +333 -0
  68. dnakit/download/sequence.py +133 -0
  69. dnakit/download/ucsc.py +99 -0
  70. dnakit/evaluation/__init__.py +81 -0
  71. dnakit/evaluation/_shared.py +288 -0
  72. dnakit/evaluation/collection.py +270 -0
  73. dnakit/evaluation/config.py +591 -0
  74. dnakit/evaluation/distribution.py +287 -0
  75. dnakit/evaluation/frechet.py +223 -0
  76. dnakit/evaluation/generative.py +381 -0
  77. dnakit/evaluation/reference.py +409 -0
  78. dnakit/evaluation/results.py +137 -0
  79. dnakit/evaluation/scorecard.py +211 -0
  80. dnakit/evaluation/sequence.py +403 -0
  81. dnakit/evaluation/synthesis.py +249 -0
  82. dnakit/exceptions.py +189 -0
  83. dnakit/fingerprints/__init__.py +109 -0
  84. dnakit/fingerprints/_shared.py +116 -0
  85. dnakit/fingerprints/advanced.py +829 -0
  86. dnakit/fingerprints/bit.py +229 -0
  87. dnakit/fingerprints/encoding.py +351 -0
  88. dnakit/fingerprints/kmer.py +170 -0
  89. dnakit/fingerprints/results.py +531 -0
  90. dnakit/fingerprints/sketch.py +246 -0
  91. dnakit/io/__init__.py +112 -0
  92. dnakit/io/_advanced_common.py +105 -0
  93. dnakit/io/_formats.py +994 -0
  94. dnakit/io/annotations.py +1179 -0
  95. dnakit/io/api.py +759 -0
  96. dnakit/io/config.py +315 -0
  97. dnakit/io/genbank.py +523 -0
  98. dnakit/io/indexed.py +1146 -0
  99. dnakit/io/metadata.py +452 -0
  100. dnakit/io/parquet.py +56 -0
  101. dnakit/io/results.py +131 -0
  102. dnakit/io/source.py +138 -0
  103. dnakit/io/tables.py +1237 -0
  104. dnakit/molbio/__init__.py +101 -0
  105. dnakit/molbio/_shared.py +266 -0
  106. dnakit/molbio/assembly.py +279 -0
  107. dnakit/molbio/crispr.py +181 -0
  108. dnakit/molbio/optimization.py +897 -0
  109. dnakit/molbio/primer3_design.py +667 -0
  110. dnakit/molbio/primers.py +899 -0
  111. dnakit/molbio/restriction.py +573 -0
  112. dnakit/molbio/results.py +583 -0
  113. dnakit/ops/__init__.py +588 -0
  114. dnakit/ops/_common.py +201 -0
  115. dnakit/ops/circular.py +193 -0
  116. dnakit/ops/concat.py +204 -0
  117. dnakit/ops/direction.py +27 -0
  118. dnakit/ops/edit.py +443 -0
  119. dnakit/ops/evolution.py +991 -0
  120. dnakit/ops/mutation.py +151 -0
  121. dnakit/ops/records.py +1012 -0
  122. dnakit/ops/translation.py +257 -0
  123. dnakit/patterns/__init__.py +81 -0
  124. dnakit/patterns/_shared.py +282 -0
  125. dnakit/patterns/coding.py +360 -0
  126. dnakit/patterns/crispr.py +337 -0
  127. dnakit/patterns/motif.py +721 -0
  128. dnakit/patterns/regions.py +341 -0
  129. dnakit/patterns/repeats.py +480 -0
  130. dnakit/patterns/restriction.py +281 -0
  131. dnakit/patterns/results.py +411 -0
  132. dnakit/py.typed +1 -0
  133. dnakit/references/__init__.py +27 -0
  134. dnakit/references/models.py +155 -0
  135. dnakit/references/ncbi.py +785 -0
  136. dnakit/representations/__init__.py +29 -0
  137. dnakit/representations/backends.py +928 -0
  138. dnakit/representations/checkpoints.py +356 -0
  139. dnakit/representations/extraction.py +220 -0
  140. dnakit/representations/models.py +395 -0
  141. dnakit/representations/results.py +79 -0
  142. dnakit/search/__init__.py +132 -0
  143. dnakit/search/_http.py +271 -0
  144. dnakit/search/_shared.py +108 -0
  145. dnakit/search/blast.py +516 -0
  146. dnakit/search/ena.py +245 -0
  147. dnakit/search/encode.py +102 -0
  148. dnakit/search/ensembl.py +874 -0
  149. dnakit/search/models.py +159 -0
  150. dnakit/search/ncbi.py +756 -0
  151. dnakit/search/ucsc.py +388 -0
  152. dnakit/secondary_structure/__init__.py +38 -0
  153. dnakit/secondary_structure/dotbracket.py +410 -0
  154. dnakit/secondary_structure/nupack.py +589 -0
  155. dnakit/secondary_structure/results.py +142 -0
  156. dnakit/similarity/__init__.py +125 -0
  157. dnakit/similarity/_shared.py +152 -0
  158. dnakit/similarity/approximate.py +258 -0
  159. dnakit/similarity/compare.py +162 -0
  160. dnakit/similarity/dashing.py +936 -0
  161. dnakit/similarity/distance.py +279 -0
  162. dnakit/similarity/index.py +365 -0
  163. dnakit/similarity/matrix.py +254 -0
  164. dnakit/similarity/results.py +676 -0
  165. dnakit/similarity/search.py +286 -0
  166. dnakit/similarity/sketch.py +96 -0
  167. dnakit/similarity/vector.py +662 -0
  168. dnakit/standardize/__init__.py +77 -0
  169. dnakit/standardize/_shared.py +100 -0
  170. dnakit/standardize/config.py +411 -0
  171. dnakit/standardize/gaps.py +1060 -0
  172. dnakit/standardize/normalize.py +565 -0
  173. dnakit/standardize/results.py +247 -0
  174. dnakit/standardize/validate.py +487 -0
  175. dnakit/structure3d/__init__.py +47 -0
  176. dnakit/structure3d/_shared.py +47 -0
  177. dnakit/structure3d/geometry.py +773 -0
  178. dnakit/structure3d/pdb.py +253 -0
  179. dnakit/structure3d/results.py +233 -0
  180. dnakit/structure3d/threedna.py +174 -0
  181. dnakit/thermodynamics/__init__.py +112 -0
  182. dnakit/thermodynamics/_shared.py +92 -0
  183. dnakit/thermodynamics/backends.py +653 -0
  184. dnakit/thermodynamics/calculations.py +837 -0
  185. dnakit/thermodynamics/config.py +175 -0
  186. dnakit/thermodynamics/equilibrium.py +340 -0
  187. dnakit/thermodynamics/optics.py +385 -0
  188. dnakit/thermodynamics/parameters.py +79 -0
  189. dnakit/thermodynamics/results.py +445 -0
  190. dnakit/visualization/__init__.py +43 -0
  191. dnakit/visualization/_svg.py +185 -0
  192. dnakit/visualization/advanced.py +314 -0
  193. dnakit/visualization/config.py +255 -0
  194. dnakit/visualization/export.py +349 -0
  195. dnakit/visualization/matrix.py +279 -0
  196. dnakit/visualization/report.py +431 -0
  197. dnakit/visualization/results.py +236 -0
  198. dnakit/visualization/sequence.py +459 -0
  199. dnakit/workflows/__init__.py +45 -0
  200. dnakit/workflows/manifest.py +365 -0
  201. dnakit/workflows/runner.py +1548 -0
  202. dnakit/workflows/schema.py +898 -0
  203. dnakit-0.1.0.dev0.dist-info/METADATA +126 -0
  204. dnakit-0.1.0.dev0.dist-info/RECORD +210 -0
  205. dnakit-0.1.0.dev0.dist-info/WHEEL +5 -0
  206. dnakit-0.1.0.dev0.dist-info/entry_points.txt +2 -0
  207. dnakit-0.1.0.dev0.dist-info/licenses/DISCLAIMER.md +26 -0
  208. dnakit-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
  209. dnakit-0.1.0.dev0.dist-info/licenses/THIRD_PARTY_NOTICES.md +73 -0
  210. 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
+ ]