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/metadata.py ADDED
@@ -0,0 +1,452 @@
1
+ """Immutable metadata add, merge, filter, projection, and validation APIs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Iterable, Mapping
6
+ from dataclasses import dataclass
7
+ from typing import Any, Literal, cast, overload
8
+
9
+ from dnakit.core._json import JSONValue, to_json_compatible
10
+ from dnakit.core.collection import DNASet
11
+ from dnakit.core.facade import DNA
12
+ from dnakit.core.record import DNARecord
13
+ from dnakit.exceptions import ConfigurationError, InputFormatError
14
+
15
+ ConflictPolicy = Literal["error", "replace", "keep"]
16
+ MissingPolicy = Literal["error", "keep", "drop"]
17
+ ExtraPolicy = Literal["error", "ignore"]
18
+ MetadataType = Literal["string", "integer", "number", "boolean", "null", "array", "object"]
19
+ MetadataPredicate = Callable[[JSONValue], bool]
20
+ MetadataRecordInput = DNA | DNARecord
21
+ MetadataDatasetInput = DNA | DNASet
22
+
23
+
24
+ def _copy_record(record: DNARecord, metadata: Mapping[str, object]) -> DNARecord:
25
+ return DNARecord(
26
+ record.sequence,
27
+ record.id,
28
+ description=record.description,
29
+ features=record.features,
30
+ metadata=metadata,
31
+ letter_annotations=record.letter_annotations,
32
+ )
33
+
34
+
35
+ def _copy_set(dataset: DNASet, records: Iterable[DNARecord]) -> DNASet:
36
+ return DNASet(
37
+ records,
38
+ name=dataset.name,
39
+ source=dataset.source,
40
+ version=dataset.version,
41
+ metadata=dataset.metadata,
42
+ )
43
+
44
+
45
+ def _record_value(value: MetadataRecordInput) -> DNARecord:
46
+ if isinstance(value, DNA):
47
+ return value.record
48
+ if isinstance(value, DNARecord):
49
+ return value
50
+ raise TypeError("record must be DNA or DNARecord.")
51
+
52
+
53
+ def _record_result(source: MetadataRecordInput, record: DNARecord) -> DNA | DNARecord:
54
+ return source._derive(((0, record),)) if isinstance(source, DNA) else record
55
+
56
+
57
+ def _dataset_value(value: MetadataDatasetInput) -> DNASet:
58
+ if isinstance(value, DNA):
59
+ return value.dataset
60
+ if isinstance(value, DNASet):
61
+ return value
62
+ raise TypeError("dataset must be DNA or DNASet.")
63
+
64
+
65
+ def _dataset_result(
66
+ source: MetadataDatasetInput,
67
+ indexed_records: Iterable[tuple[int, DNARecord]],
68
+ ) -> DNA | DNASet:
69
+ selected = tuple(indexed_records)
70
+ if isinstance(source, DNA):
71
+ return source._derive(selected)
72
+ return _copy_set(source, (record for _, record in selected))
73
+
74
+
75
+ @overload
76
+ def with_metadata(
77
+ record: DNA,
78
+ values: Mapping[str, object],
79
+ *,
80
+ conflict: ConflictPolicy = "error",
81
+ ) -> DNA: ...
82
+
83
+
84
+ @overload
85
+ def with_metadata(
86
+ record: DNARecord,
87
+ values: Mapping[str, object],
88
+ *,
89
+ conflict: ConflictPolicy = "error",
90
+ ) -> DNARecord: ...
91
+
92
+
93
+ def with_metadata(
94
+ record: MetadataRecordInput,
95
+ values: Mapping[str, object],
96
+ *,
97
+ conflict: ConflictPolicy = "error",
98
+ ) -> DNA | DNARecord:
99
+ """Return a new record after a top-level immutable metadata merge."""
100
+
101
+ source_record = _record_value(record)
102
+ if not isinstance(values, Mapping):
103
+ raise TypeError("values must be a mapping.")
104
+ if conflict not in {"error", "replace", "keep"}:
105
+ raise ConfigurationError(
106
+ "Unknown metadata conflict policy.", code="INVALID_METADATA_POLICY"
107
+ )
108
+ result: dict[str, object] = dict(source_record.metadata)
109
+ for key, value in values.items():
110
+ if not isinstance(key, str) or not key:
111
+ raise ConfigurationError(
112
+ "Metadata keys must be non-empty strings.", code="INVALID_METADATA_KEY"
113
+ )
114
+ if key in result:
115
+ if conflict == "error" and result[key] != value:
116
+ raise InputFormatError(
117
+ "Metadata merge encountered a conflicting value.",
118
+ code="METADATA_CONFLICT",
119
+ context={"record_id": source_record.id, "field": key},
120
+ )
121
+ if conflict == "keep":
122
+ continue
123
+ result[key] = value
124
+ return _record_result(record, _copy_record(source_record, result))
125
+
126
+
127
+ @overload
128
+ def merge_metadata(
129
+ dataset: DNA,
130
+ metadata_by_id: Mapping[str, Mapping[str, object]],
131
+ *,
132
+ conflict: ConflictPolicy = "error",
133
+ missing: MissingPolicy = "error",
134
+ extra: ExtraPolicy = "error",
135
+ ) -> DNA: ...
136
+
137
+
138
+ @overload
139
+ def merge_metadata(
140
+ dataset: DNASet,
141
+ metadata_by_id: Mapping[str, Mapping[str, object]],
142
+ *,
143
+ conflict: ConflictPolicy = "error",
144
+ missing: MissingPolicy = "error",
145
+ extra: ExtraPolicy = "error",
146
+ ) -> DNASet: ...
147
+
148
+
149
+ def merge_metadata(
150
+ dataset: MetadataDatasetInput,
151
+ metadata_by_id: Mapping[str, Mapping[str, object]],
152
+ *,
153
+ conflict: ConflictPolicy = "error",
154
+ missing: MissingPolicy = "error",
155
+ extra: ExtraPolicy = "error",
156
+ ) -> DNA | DNASet:
157
+ """Left-join metadata by exact record ID with explicit missing/extra policies."""
158
+
159
+ source_dataset = _dataset_value(dataset)
160
+ if not isinstance(metadata_by_id, Mapping):
161
+ raise TypeError("metadata_by_id must be a mapping.")
162
+ if missing not in {"error", "keep", "drop"} or extra not in {"error", "ignore"}:
163
+ raise ConfigurationError("Unknown metadata join policy.", code="INVALID_METADATA_POLICY")
164
+ if len(set(source_dataset.ids)) != len(source_dataset.ids):
165
+ raise InputFormatError(
166
+ "Metadata joins require unique DNASet record IDs.",
167
+ code="DUPLICATE_METADATA_JOIN_ID",
168
+ )
169
+ invalid = tuple(
170
+ key
171
+ for key, value in metadata_by_id.items()
172
+ if not isinstance(key, str) or not key or not isinstance(value, Mapping)
173
+ )
174
+ if invalid:
175
+ raise ConfigurationError(
176
+ "Metadata join keys must be non-empty strings and values must be mappings.",
177
+ code="INVALID_METADATA_TABLE",
178
+ context={"invalid_keys": invalid[:10]},
179
+ )
180
+ input_ids = set(source_dataset.ids)
181
+ extra_ids = tuple(sorted(set(metadata_by_id) - input_ids))
182
+ if extra_ids and extra == "error":
183
+ raise InputFormatError(
184
+ "Metadata table contains IDs absent from the DNASet.",
185
+ code="EXTRA_METADATA_IDS",
186
+ context={"count": len(extra_ids), "sample": extra_ids[:10]},
187
+ )
188
+ output: list[tuple[int, DNARecord]] = []
189
+ for index, record in enumerate(source_dataset):
190
+ values = metadata_by_id.get(record.id)
191
+ if values is None:
192
+ if missing == "error":
193
+ raise InputFormatError(
194
+ "A DNASet record has no matching metadata row.",
195
+ code="MISSING_METADATA_ID",
196
+ context={"record_id": record.id},
197
+ )
198
+ if missing == "keep":
199
+ output.append((index, record))
200
+ continue
201
+ output.append((index, with_metadata(record, values, conflict=conflict)))
202
+ return _dataset_result(dataset, output)
203
+
204
+
205
+ @overload
206
+ def filter_by_metadata(
207
+ dataset: DNA,
208
+ criteria: Mapping[str, object | MetadataPredicate],
209
+ *,
210
+ require_all: bool = True,
211
+ ) -> DNA: ...
212
+
213
+
214
+ @overload
215
+ def filter_by_metadata(
216
+ dataset: DNASet,
217
+ criteria: Mapping[str, object | MetadataPredicate],
218
+ *,
219
+ require_all: bool = True,
220
+ ) -> DNASet: ...
221
+
222
+
223
+ def filter_by_metadata(
224
+ dataset: MetadataDatasetInput,
225
+ criteria: Mapping[str, object | MetadataPredicate],
226
+ *,
227
+ require_all: bool = True,
228
+ ) -> DNA | DNASet:
229
+ """Filter records by exact values or side-effect-free caller predicates."""
230
+
231
+ source_dataset = _dataset_value(dataset)
232
+ if not isinstance(criteria, Mapping) or not criteria:
233
+ raise ConfigurationError(
234
+ "criteria must be a non-empty mapping.", code="INVALID_METADATA_FILTER"
235
+ )
236
+ if any(not isinstance(field, str) or not field for field in criteria):
237
+ raise ConfigurationError(
238
+ "Metadata filter fields must be non-empty strings.",
239
+ code="INVALID_METADATA_FILTER",
240
+ )
241
+ if not isinstance(require_all, bool):
242
+ raise TypeError("require_all must be a boolean.")
243
+
244
+ def matches(record: DNARecord) -> bool:
245
+ decisions: list[bool] = []
246
+ for field, expected in criteria.items():
247
+ if field not in record.metadata:
248
+ decisions.append(False)
249
+ continue
250
+ actual = record.metadata[field]
251
+ decisions.append(bool(expected(actual)) if callable(expected) else actual == expected)
252
+ return all(decisions) if require_all else any(decisions)
253
+
254
+ return _dataset_result(
255
+ dataset,
256
+ ((index, record) for index, record in enumerate(source_dataset) if matches(record)),
257
+ )
258
+
259
+
260
+ @overload
261
+ def select_metadata(
262
+ record: DNA,
263
+ fields: Iterable[str],
264
+ *,
265
+ missing: Literal["error", "ignore"] = "error",
266
+ ) -> DNA: ...
267
+
268
+
269
+ @overload
270
+ def select_metadata(
271
+ record: DNARecord,
272
+ fields: Iterable[str],
273
+ *,
274
+ missing: Literal["error", "ignore"] = "error",
275
+ ) -> DNARecord: ...
276
+
277
+
278
+ def select_metadata(
279
+ record: MetadataRecordInput,
280
+ fields: Iterable[str],
281
+ *,
282
+ missing: Literal["error", "ignore"] = "error",
283
+ ) -> DNA | DNARecord:
284
+ """Return a record containing only selected metadata fields."""
285
+
286
+ source_record = _record_value(record)
287
+ if missing not in {"error", "ignore"}:
288
+ raise ConfigurationError(
289
+ "Unknown projection missing policy.", code="INVALID_METADATA_POLICY"
290
+ )
291
+ field_tuple = tuple(fields)
292
+ if any(not isinstance(field, str) or not field for field in field_tuple):
293
+ raise ConfigurationError(
294
+ "Metadata projection fields must be non-empty strings.", code="INVALID_METADATA_KEY"
295
+ )
296
+ absent = tuple(field for field in field_tuple if field not in source_record.metadata)
297
+ if absent and missing == "error":
298
+ raise InputFormatError(
299
+ "Metadata projection references missing fields.",
300
+ code="MISSING_METADATA_FIELD",
301
+ context={"record_id": source_record.id, "fields": absent},
302
+ )
303
+ return _record_result(
304
+ record,
305
+ _copy_record(
306
+ source_record,
307
+ {
308
+ field: source_record.metadata[field]
309
+ for field in field_tuple
310
+ if field in source_record.metadata
311
+ },
312
+ ),
313
+ )
314
+
315
+
316
+ def _metadata_type(value: JSONValue) -> MetadataType:
317
+ if value is None:
318
+ return "null"
319
+ if isinstance(value, bool):
320
+ return "boolean"
321
+ if isinstance(value, int):
322
+ return "integer"
323
+ if isinstance(value, float):
324
+ return "number"
325
+ if isinstance(value, str):
326
+ return "string"
327
+ if isinstance(value, Mapping):
328
+ return "object"
329
+ return "array"
330
+
331
+
332
+ @dataclass(frozen=True, slots=True)
333
+ class MetadataValidationIssue:
334
+ record_id: str
335
+ field: str
336
+ code: Literal["missing", "type_mismatch"]
337
+ expected_type: MetadataType
338
+ actual_type: MetadataType | None
339
+
340
+ def __post_init__(self) -> None:
341
+ allowed = {"string", "integer", "number", "boolean", "null", "array", "object"}
342
+ if not isinstance(self.record_id, str) or not self.record_id:
343
+ raise ConfigurationError(
344
+ "Metadata issue record_id must be non-empty.",
345
+ code="INVALID_METADATA_REPORT",
346
+ )
347
+ if not isinstance(self.field, str) or not self.field:
348
+ raise ConfigurationError(
349
+ "Metadata issue field must be non-empty.", code="INVALID_METADATA_REPORT"
350
+ )
351
+ if (
352
+ self.code not in {"missing", "type_mismatch"}
353
+ or self.expected_type not in allowed
354
+ or (self.actual_type is not None and self.actual_type not in allowed)
355
+ ):
356
+ raise ConfigurationError(
357
+ "Metadata issue contains an invalid code or type.",
358
+ code="INVALID_METADATA_REPORT",
359
+ )
360
+ if (self.code == "missing") != (self.actual_type is None):
361
+ raise ConfigurationError(
362
+ "Metadata issue code and actual_type are inconsistent.",
363
+ code="INVALID_METADATA_REPORT",
364
+ )
365
+
366
+
367
+ @dataclass(frozen=True, slots=True)
368
+ class MetadataValidationReport:
369
+ record_count: int
370
+ issues: tuple[MetadataValidationIssue, ...]
371
+
372
+ def __post_init__(self) -> None:
373
+ if (
374
+ isinstance(self.record_count, bool)
375
+ or not isinstance(self.record_count, int)
376
+ or self.record_count < 0
377
+ or not isinstance(self.issues, tuple)
378
+ or any(not isinstance(issue, MetadataValidationIssue) for issue in self.issues)
379
+ ):
380
+ raise ConfigurationError(
381
+ "Metadata validation report fields are invalid.",
382
+ code="INVALID_METADATA_REPORT",
383
+ )
384
+
385
+ @property
386
+ def valid(self) -> bool:
387
+ return not self.issues
388
+
389
+ def to_dict(self) -> dict[str, Any]:
390
+ return cast(dict[str, Any], to_json_compatible(self))
391
+
392
+
393
+ def validate_metadata(
394
+ dataset: MetadataDatasetInput,
395
+ schema: Mapping[str, MetadataType],
396
+ *,
397
+ required: Iterable[str] = (),
398
+ ) -> MetadataValidationReport:
399
+ """Validate top-level metadata presence and stable JSON scalar/container types."""
400
+
401
+ source_dataset = _dataset_value(dataset)
402
+ allowed = {"string", "integer", "number", "boolean", "null", "array", "object"}
403
+ if not isinstance(schema, Mapping) or any(
404
+ not isinstance(field, str) or not field or not isinstance(kind, str) or kind not in allowed
405
+ for field, kind in schema.items()
406
+ ):
407
+ raise ConfigurationError(
408
+ "Metadata schema contains an invalid field or type.", code="INVALID_METADATA_SCHEMA"
409
+ )
410
+ required_tuple = tuple(required)
411
+ if any(not isinstance(field, str) or not field for field in required_tuple):
412
+ raise ConfigurationError(
413
+ "Required metadata fields must be non-empty strings.",
414
+ code="INVALID_METADATA_SCHEMA",
415
+ )
416
+ if any(field not in schema for field in required_tuple):
417
+ raise ConfigurationError(
418
+ "Every required metadata field must appear in schema.", code="INVALID_METADATA_SCHEMA"
419
+ )
420
+ issues: list[MetadataValidationIssue] = []
421
+ for record in source_dataset:
422
+ for field, expected in schema.items():
423
+ value = record.metadata.get(field)
424
+ if field not in record.metadata:
425
+ if field in required_tuple:
426
+ issues.append(
427
+ MetadataValidationIssue(record.id, field, "missing", expected, None)
428
+ )
429
+ continue
430
+ actual = _metadata_type(value)
431
+ compatible = actual == expected or (expected == "number" and actual == "integer")
432
+ if not compatible:
433
+ issues.append(
434
+ MetadataValidationIssue(record.id, field, "type_mismatch", expected, actual)
435
+ )
436
+ return MetadataValidationReport(len(source_dataset), tuple(issues))
437
+
438
+
439
+ __all__ = [
440
+ "ConflictPolicy",
441
+ "ExtraPolicy",
442
+ "MetadataPredicate",
443
+ "MetadataType",
444
+ "MetadataValidationIssue",
445
+ "MetadataValidationReport",
446
+ "MissingPolicy",
447
+ "filter_by_metadata",
448
+ "merge_metadata",
449
+ "select_metadata",
450
+ "validate_metadata",
451
+ "with_metadata",
452
+ ]
dnakit/io/parquet.py ADDED
@@ -0,0 +1,56 @@
1
+ """Conditional availability probe for the optional PyArrow Parquet adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.util
6
+ from dataclasses import dataclass
7
+ from importlib.metadata import PackageNotFoundError, version
8
+ from typing import Any, cast
9
+
10
+ from dnakit.core._json import to_json_compatible
11
+ from dnakit.exceptions import BackendUnavailableError
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class ParquetBackendStatus:
16
+ available: bool
17
+ distribution: str = "pyarrow"
18
+ version: str | None = None
19
+ implementation: str = "conditional-adapter"
20
+
21
+ def to_dict(self) -> dict[str, Any]:
22
+ return cast(dict[str, Any], to_json_compatible(self))
23
+
24
+
25
+ def parquet_backend_status() -> ParquetBackendStatus:
26
+ """Probe without importing PyArrow or mutating the environment."""
27
+
28
+ try:
29
+ available = importlib.util.find_spec("pyarrow") is not None
30
+ except (ImportError, AttributeError, ValueError):
31
+ available = False
32
+ resolved_version: str | None = None
33
+ if available:
34
+ try:
35
+ resolved_version = version("pyarrow")
36
+ except PackageNotFoundError:
37
+ available = False
38
+ return ParquetBackendStatus(available, version=resolved_version)
39
+
40
+
41
+ def require_parquet_backend() -> ParquetBackendStatus:
42
+ """Return backend metadata or raise one stable actionable error."""
43
+
44
+ status = parquet_backend_status()
45
+ if not status.available:
46
+ raise BackendUnavailableError(
47
+ "The optional PyArrow Parquet backend is not installed.",
48
+ code="PARQUET_BACKEND_UNAVAILABLE",
49
+ hint=(
50
+ "Install a compatible pyarrow build in the local environment before using Parquet."
51
+ ),
52
+ )
53
+ return status
54
+
55
+
56
+ __all__ = ["ParquetBackendStatus", "parquet_backend_status", "require_parquet_backend"]
dnakit/io/results.py ADDED
@@ -0,0 +1,131 @@
1
+ """Structured results produced by DNAKit writers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable, Mapping
6
+ from dataclasses import dataclass
7
+ from typing import Any, cast
8
+
9
+ from dnakit.core._json import FrozenDict, freeze_mapping, to_json_compatible
10
+ from dnakit.core.enums import ExecutionMode, ImplementationLabel, OriginClass
11
+ from dnakit.core.issues import Issue
12
+ from dnakit.core.provenance import ArtifactRef, ImplementationInfo, Provenance
13
+ from dnakit.exceptions import ConfigurationError
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class GeneratedID:
18
+ """Stable ID assigned to an anonymous sequence at one input position."""
19
+
20
+ input_index: int
21
+ generated_id: str
22
+
23
+ def __post_init__(self) -> None:
24
+ if (
25
+ isinstance(self.input_index, bool)
26
+ or not isinstance(self.input_index, int)
27
+ or self.input_index < 0
28
+ ):
29
+ raise ConfigurationError(
30
+ "GeneratedID input_index must be a non-negative integer.",
31
+ code="INVALID_GENERATED_ID",
32
+ )
33
+ if not isinstance(self.generated_id, str) or not self.generated_id.strip():
34
+ raise ConfigurationError(
35
+ "GeneratedID generated_id must be a non-empty string.",
36
+ code="INVALID_GENERATED_ID",
37
+ )
38
+
39
+
40
+ @dataclass(frozen=True, init=False)
41
+ class WriteResult:
42
+ """Auditable summary of a completed serialization operation."""
43
+
44
+ format: str
45
+ record_count: int
46
+ byte_count: int | None
47
+ generated_ids: tuple[GeneratedID, ...]
48
+ target_artifact: ArtifactRef | None
49
+ parameters: FrozenDict
50
+ provenance: Provenance
51
+ issues: tuple[Issue, ...]
52
+
53
+ def __init__(
54
+ self,
55
+ format: str,
56
+ record_count: int,
57
+ *,
58
+ byte_count: int | None = None,
59
+ generated_ids: Iterable[GeneratedID] = (),
60
+ target_artifact: ArtifactRef | None = None,
61
+ parameters: Mapping[str, object] | None = None,
62
+ provenance: Provenance | None = None,
63
+ issues: Iterable[Issue] = (),
64
+ ) -> None:
65
+ if not isinstance(format, str) or not format.strip():
66
+ raise ConfigurationError(
67
+ "WriteResult format must be a non-empty string.",
68
+ code="INVALID_WRITE_RESULT",
69
+ )
70
+ if isinstance(record_count, bool) or not isinstance(record_count, int) or record_count < 0:
71
+ raise ConfigurationError(
72
+ "WriteResult record_count must be a non-negative integer.",
73
+ code="INVALID_WRITE_RESULT",
74
+ )
75
+ if byte_count is not None and (
76
+ isinstance(byte_count, bool) or not isinstance(byte_count, int) or byte_count < 0
77
+ ):
78
+ raise ConfigurationError(
79
+ "WriteResult byte_count must be non-negative or None.",
80
+ code="INVALID_WRITE_RESULT",
81
+ )
82
+ generated_tuple = tuple(generated_ids)
83
+ issue_tuple = tuple(issues)
84
+ if any(not isinstance(item, GeneratedID) for item in generated_tuple):
85
+ raise ConfigurationError(
86
+ "WriteResult generated_ids must contain GeneratedID objects.",
87
+ code="INVALID_WRITE_RESULT",
88
+ )
89
+ if any(not isinstance(item, Issue) for item in issue_tuple):
90
+ raise ConfigurationError(
91
+ "WriteResult issues must contain Issue objects.",
92
+ code="INVALID_WRITE_RESULT",
93
+ )
94
+ if target_artifact is not None and not isinstance(target_artifact, ArtifactRef):
95
+ raise ConfigurationError(
96
+ "WriteResult target_artifact must be ArtifactRef or None.",
97
+ code="INVALID_WRITE_RESULT",
98
+ )
99
+ if provenance is not None and not isinstance(provenance, Provenance):
100
+ raise ConfigurationError(
101
+ "WriteResult provenance must be Provenance or None.",
102
+ code="INVALID_WRITE_RESULT",
103
+ )
104
+ object.__setattr__(self, "format", format)
105
+ object.__setattr__(self, "record_count", record_count)
106
+ object.__setattr__(self, "byte_count", byte_count)
107
+ object.__setattr__(self, "generated_ids", generated_tuple)
108
+ object.__setattr__(self, "target_artifact", target_artifact)
109
+ object.__setattr__(self, "parameters", freeze_mapping(parameters))
110
+ object.__setattr__(
111
+ self,
112
+ "provenance",
113
+ provenance
114
+ if provenance is not None
115
+ else Provenance(
116
+ implementation=ImplementationInfo(
117
+ ImplementationLabel.REIMPLEMENTATION,
118
+ ExecutionMode.INTERNAL,
119
+ OriginClass.STANDARD,
120
+ )
121
+ ),
122
+ )
123
+ object.__setattr__(self, "issues", issue_tuple)
124
+
125
+ def to_dict(self) -> dict[str, Any]:
126
+ """Return a JSON-compatible representation."""
127
+
128
+ return cast(dict[str, Any], to_json_compatible(self))
129
+
130
+
131
+ __all__ = ["GeneratedID", "WriteResult"]