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