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/cli/workflow.py ADDED
@@ -0,0 +1,94 @@
1
+ """Standalone local CLI for strict JSON/YAML DNAKit workflows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ from collections.abc import Sequence
9
+ from pathlib import Path
10
+
11
+ from rich.console import Console
12
+ from rich.progress import BarColumn, Progress, SpinnerColumn, TaskProgressColumn, TextColumn
13
+
14
+ from dnakit.exceptions import DNAKitError
15
+ from dnakit.workflows import WorkflowProgress, WorkflowRunResult, run_workflow
16
+
17
+
18
+ def _parser() -> argparse.ArgumentParser:
19
+ parser = argparse.ArgumentParser(
20
+ prog="python -m dnakit.cli.workflow",
21
+ description="Run a strict, bounded, local DNAKit JSON/YAML workflow.",
22
+ )
23
+ commands = parser.add_subparsers(dest="command", required=True)
24
+ run = commands.add_parser("run", help="validate and execute one workflow")
25
+ run.add_argument("config", type=Path, help="workflow .json/.yaml path")
26
+ run.add_argument("--dry-run", action="store_true", help="validate without writing")
27
+ run.add_argument("--resume", action="store_true", help="verify hashes and resume outputs")
28
+ run.add_argument("--no-progress", action="store_true", help="disable the Rich progress bar")
29
+ return parser
30
+
31
+
32
+ def _run_with_progress(config: Path, *, dry_run: bool, resume: bool) -> WorkflowRunResult:
33
+ progress = Progress(
34
+ SpinnerColumn(),
35
+ TextColumn("{task.description}"),
36
+ BarColumn(),
37
+ TaskProgressColumn(),
38
+ transient=True,
39
+ )
40
+ task = progress.add_task("DNAKit workflow", total=1)
41
+
42
+ def update(event: WorkflowProgress) -> None:
43
+ total = max(1, event.total)
44
+ completed = event.index if event.status != "started" else max(0, event.index - 1)
45
+ progress.update(
46
+ task,
47
+ total=total,
48
+ completed=min(completed, total),
49
+ description=f"{event.step_id}: {event.status}",
50
+ )
51
+
52
+ with progress:
53
+ return run_workflow(config, dry_run=dry_run, resume=resume, progress=update)
54
+
55
+
56
+ def main(argv: Sequence[str] | None = None) -> int:
57
+ """Run the standalone workflow command and return a process exit status."""
58
+
59
+ arguments = _parser().parse_args(None if argv is None else list(argv))
60
+ console = Console(stderr=True)
61
+ try:
62
+ result = (
63
+ run_workflow(
64
+ arguments.config,
65
+ dry_run=arguments.dry_run,
66
+ resume=arguments.resume,
67
+ )
68
+ if arguments.no_progress or not os.isatty(2)
69
+ else _run_with_progress(
70
+ arguments.config,
71
+ dry_run=arguments.dry_run,
72
+ resume=arguments.resume,
73
+ )
74
+ )
75
+ except (DNAKitError, OSError) as error:
76
+ console.print(f"[red]DNAKit workflow failed:[/red] {error}")
77
+ return 2
78
+ print(
79
+ json.dumps(
80
+ result.to_dict(),
81
+ ensure_ascii=False,
82
+ sort_keys=True,
83
+ indent=2,
84
+ allow_nan=False,
85
+ )
86
+ )
87
+ return 0 if result.status != "failed" else 1
88
+
89
+
90
+ if __name__ == "__main__":
91
+ raise SystemExit(main())
92
+
93
+
94
+ __all__ = ["main"]
@@ -0,0 +1,5 @@
1
+ """Strict layered configuration loading for reproducible workflows."""
2
+
3
+ from dnakit.config.loader import ConfigLayer, LoadedConfig, load_config
4
+
5
+ __all__ = ["ConfigLayer", "LoadedConfig", "load_config"]
@@ -0,0 +1,381 @@
1
+ """Load JSON/TOML configuration with explicit precedence and validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from collections.abc import Iterable, Mapping
8
+ from dataclasses import dataclass
9
+ from itertools import islice
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING, Literal, cast
12
+
13
+ import yaml # type: ignore[import-untyped]
14
+
15
+ if TYPE_CHECKING:
16
+ import tomli as toml_reader
17
+ else:
18
+ try:
19
+ import tomllib as toml_reader
20
+ except ImportError: # pragma: no cover - Python 3.10 compatibility branch.
21
+ import tomli as toml_reader
22
+
23
+ from dnakit.core._json import FrozenDict, freeze_mapping, to_json_compatible
24
+ from dnakit.exceptions import ConfigurationError, InputFormatError
25
+
26
+ ConfigFormat = Literal["json", "toml", "yaml"]
27
+ _MAX_CONFIG_BYTES = 1_000_000
28
+ _MAX_CONFIG_DEPTH = 32
29
+ _MAX_CONFIG_NODES = 100_000
30
+ _MAX_CONFIG_KEYS = 100_000
31
+
32
+
33
+ def _unique_json_object(pairs: list[tuple[str, object]]) -> dict[str, object]:
34
+ values: dict[str, object] = {}
35
+ for key, value in pairs:
36
+ if key in values:
37
+ raise InputFormatError(
38
+ "Configuration contains a duplicate key.",
39
+ code="DUPLICATE_CONFIG_KEY",
40
+ context={"key": key},
41
+ )
42
+ values[key] = value
43
+ return values
44
+
45
+
46
+ class _UniqueKeySafeLoader(yaml.SafeLoader): # type: ignore[misc]
47
+ """Safe YAML loader that rejects duplicate keys."""
48
+
49
+
50
+ def _construct_unique_mapping(
51
+ loader: yaml.SafeLoader, node: yaml.nodes.MappingNode, deep: bool = False
52
+ ) -> dict[object, object]:
53
+ loader.flatten_mapping(node)
54
+ values: dict[object, object] = {}
55
+ for key_node, value_node in node.value:
56
+ key = loader.construct_object(key_node, deep=deep)
57
+ try:
58
+ duplicate = key in values
59
+ except TypeError as exc:
60
+ raise InputFormatError(
61
+ "Configuration keys must be hashable strings.", code="INVALID_CONFIG_KEY"
62
+ ) from exc
63
+ if duplicate:
64
+ raise InputFormatError(
65
+ "Configuration contains a duplicate key.",
66
+ code="DUPLICATE_CONFIG_KEY",
67
+ context={"key": str(key)},
68
+ )
69
+ values[key] = loader.construct_object(value_node, deep=deep)
70
+ return values
71
+
72
+
73
+ _UniqueKeySafeLoader.add_constructor(
74
+ yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG,
75
+ _construct_unique_mapping,
76
+ )
77
+
78
+
79
+ def _validate_structure(value: object) -> None:
80
+ nodes = [0]
81
+ active: set[int] = set()
82
+
83
+ def visit(item: object, depth: int) -> None:
84
+ nodes[0] += 1
85
+ if nodes[0] > _MAX_CONFIG_NODES or depth > _MAX_CONFIG_DEPTH:
86
+ raise InputFormatError(
87
+ "Configuration exceeds structural limits.",
88
+ code="CONFIG_STRUCTURE_LIMIT",
89
+ context={"max_nodes": _MAX_CONFIG_NODES, "max_depth": _MAX_CONFIG_DEPTH},
90
+ )
91
+ if isinstance(item, (Mapping, list, tuple)):
92
+ identity = id(item)
93
+ if identity in active:
94
+ raise InputFormatError(
95
+ "Configuration contains a recursive reference.",
96
+ code="CONFIG_RECURSIVE_REFERENCE",
97
+ )
98
+ active.add(identity)
99
+ try:
100
+ if isinstance(item, Mapping):
101
+ for key in item:
102
+ visit(key, depth + 1)
103
+ visit(item[key], depth + 1)
104
+ else:
105
+ for child in item:
106
+ visit(child, depth + 1)
107
+ finally:
108
+ active.remove(identity)
109
+
110
+ visit(value, 0)
111
+
112
+
113
+ @dataclass(frozen=True, slots=True)
114
+ class ConfigLayer:
115
+ """One applied configuration source in precedence order."""
116
+
117
+ source: str
118
+ values: FrozenDict
119
+
120
+ def __init__(self, source: str, values: Mapping[str, object]) -> None:
121
+ if not isinstance(source, str) or not source.strip():
122
+ raise ConfigurationError("Config layer source must be non-empty.")
123
+ object.__setattr__(self, "source", source)
124
+ object.__setattr__(self, "values", freeze_mapping(values))
125
+
126
+
127
+ @dataclass(frozen=True, slots=True)
128
+ class LoadedConfig:
129
+ """Fully resolved configuration plus its ordered source audit."""
130
+
131
+ values: FrozenDict
132
+ layers: tuple[ConfigLayer, ...]
133
+
134
+ def __post_init__(self) -> None:
135
+ if not isinstance(self.values, FrozenDict):
136
+ raise ConfigurationError("LoadedConfig values must be FrozenDict.")
137
+ if (
138
+ not isinstance(self.layers, tuple)
139
+ or not self.layers
140
+ or any(not isinstance(layer, ConfigLayer) for layer in self.layers)
141
+ ):
142
+ raise ConfigurationError("LoadedConfig layers must contain ConfigLayer objects.")
143
+
144
+ def to_dict(self) -> dict[str, object]:
145
+ return cast(dict[str, object], to_json_compatible(self))
146
+
147
+
148
+ def _plain_mapping(value: object, *, source: str) -> dict[str, object]:
149
+ if not isinstance(value, Mapping):
150
+ raise InputFormatError(
151
+ "Configuration root must be an object/table.",
152
+ code="INVALID_CONFIG_ROOT",
153
+ context={"source": source},
154
+ )
155
+ result: dict[str, object] = {}
156
+ for key, item in value.items():
157
+ if not isinstance(key, str) or not key:
158
+ raise InputFormatError(
159
+ "Configuration keys must be non-empty strings.",
160
+ code="INVALID_CONFIG_KEY",
161
+ context={"source": source},
162
+ )
163
+ result[key] = item
164
+ freeze_mapping(result)
165
+ return result
166
+
167
+
168
+ def _read_file(path: Path, format: ConfigFormat | None) -> dict[str, object]:
169
+ if not path.is_file():
170
+ raise FileNotFoundError(f"Configuration file does not exist: {path}")
171
+ if path.is_symlink():
172
+ raise InputFormatError(
173
+ "Configuration must be a regular non-symlink file.",
174
+ code="INVALID_CONFIG_PATH",
175
+ context={"path": str(path)},
176
+ )
177
+ byte_size = path.stat().st_size
178
+ if byte_size > _MAX_CONFIG_BYTES:
179
+ raise InputFormatError(
180
+ "Configuration exceeds the byte limit.",
181
+ code="CONFIG_SIZE_LIMIT",
182
+ context={"byte_size": byte_size, "max_bytes": _MAX_CONFIG_BYTES},
183
+ )
184
+ resolved_format = format
185
+ if resolved_format is None:
186
+ suffix = path.suffix.lower()
187
+ resolved_format = (
188
+ "toml"
189
+ if suffix == ".toml"
190
+ else "json"
191
+ if suffix == ".json"
192
+ else "yaml"
193
+ if suffix in {".yaml", ".yml"}
194
+ else None
195
+ )
196
+ if resolved_format not in ("json", "toml", "yaml"):
197
+ raise ConfigurationError(
198
+ "Configuration format must be json, toml, or yaml.",
199
+ code="UNSUPPORTED_CONFIG_FORMAT",
200
+ context={"path": str(path), "format": format},
201
+ )
202
+ try:
203
+ with path.open("rb") as handle:
204
+ raw = handle.read(_MAX_CONFIG_BYTES + 1)
205
+ if len(raw) > _MAX_CONFIG_BYTES:
206
+ raise InputFormatError(
207
+ "Configuration grew beyond the byte limit while being read.",
208
+ code="CONFIG_SIZE_LIMIT",
209
+ context={"max_bytes": _MAX_CONFIG_BYTES},
210
+ )
211
+ if resolved_format == "json":
212
+ parsed: object = json.loads(
213
+ raw.decode("utf-8"), object_pairs_hook=_unique_json_object
214
+ )
215
+ elif resolved_format == "toml":
216
+ parsed = toml_reader.loads(raw.decode("utf-8"))
217
+ else:
218
+ text = raw.decode("utf-8")
219
+ if any(
220
+ isinstance(token, yaml.tokens.AliasToken)
221
+ for token in yaml.scan(text, Loader=_UniqueKeySafeLoader)
222
+ ):
223
+ raise InputFormatError(
224
+ "Configuration YAML aliases are disabled.",
225
+ code="CONFIG_YAML_ALIAS_DISABLED",
226
+ )
227
+ parsed = yaml.load(text, Loader=_UniqueKeySafeLoader)
228
+ except (
229
+ UnicodeError,
230
+ json.JSONDecodeError,
231
+ toml_reader.TOMLDecodeError,
232
+ yaml.YAMLError,
233
+ ValueError,
234
+ RecursionError,
235
+ ) as exc:
236
+ raise InputFormatError(
237
+ "Configuration file could not be parsed.",
238
+ code="INVALID_CONFIG_FILE",
239
+ context={"path": str(path), "format": resolved_format},
240
+ ) from exc
241
+ _validate_structure(parsed)
242
+ return _plain_mapping(parsed, source=str(path))
243
+
244
+
245
+ def _environment_values(
246
+ environment: Mapping[str, str],
247
+ *,
248
+ prefix: str,
249
+ allowed_keys: frozenset[str],
250
+ ) -> dict[str, object]:
251
+ if not isinstance(environment, Mapping) or any(
252
+ not isinstance(name, str) or not isinstance(value, str)
253
+ for name, value in environment.items()
254
+ ):
255
+ raise ConfigurationError(
256
+ "environment must map string names to string values.",
257
+ code="INVALID_CONFIG_ENVIRONMENT",
258
+ )
259
+ result: dict[str, object] = {}
260
+ for name in sorted(environment):
261
+ if not name.startswith(prefix):
262
+ continue
263
+ key = name[len(prefix) :].lower()
264
+ if not key or key not in allowed_keys:
265
+ raise ConfigurationError(
266
+ "Unknown DNAKit environment configuration key.",
267
+ code="UNKNOWN_CONFIG_KEY",
268
+ context={"environment_variable": name},
269
+ )
270
+ raw = environment[name]
271
+ if len(raw.encode("utf-8")) > _MAX_CONFIG_BYTES:
272
+ raise InputFormatError(
273
+ "Environment configuration value exceeds the byte limit.",
274
+ code="CONFIG_ENVIRONMENT_SIZE_LIMIT",
275
+ context={"environment_variable": name, "max_bytes": _MAX_CONFIG_BYTES},
276
+ )
277
+ try:
278
+ result[key] = json.loads(raw)
279
+ except json.JSONDecodeError:
280
+ result[key] = raw
281
+ except RecursionError as exc:
282
+ raise InputFormatError(
283
+ "Environment configuration value exceeds structural limits.",
284
+ code="CONFIG_STRUCTURE_LIMIT",
285
+ context={"environment_variable": name, "max_depth": _MAX_CONFIG_DEPTH},
286
+ ) from exc
287
+ _validate_structure(result[key])
288
+ return result
289
+
290
+
291
+ def _validated_layer(
292
+ source: str,
293
+ values: Mapping[str, object],
294
+ *,
295
+ allowed_keys: frozenset[str],
296
+ ) -> ConfigLayer:
297
+ _validate_structure(values)
298
+ unknown = sorted(set(values) - allowed_keys)
299
+ if unknown:
300
+ raise ConfigurationError(
301
+ "Configuration contains unknown keys.",
302
+ code="UNKNOWN_CONFIG_KEY",
303
+ context={"source": source, "keys": unknown},
304
+ )
305
+ return ConfigLayer(source, values)
306
+
307
+
308
+ def load_config(
309
+ *,
310
+ defaults: Mapping[str, object],
311
+ allowed_keys: Iterable[str],
312
+ path: str | os.PathLike[str] | None = None,
313
+ format: ConfigFormat | None = None,
314
+ environment: Mapping[str, str] | None = None,
315
+ env_prefix: str = "DNAKIT_",
316
+ cli: Mapping[str, object] | None = None,
317
+ ) -> LoadedConfig:
318
+ """Resolve defaults < file < environment < explicit CLI values."""
319
+
320
+ if not isinstance(defaults, Mapping):
321
+ raise ConfigurationError("defaults must be a mapping.", code="INVALID_CONFIG_DEFAULTS")
322
+ if isinstance(allowed_keys, (str, bytes)):
323
+ raise ConfigurationError("allowed_keys must be an iterable of configuration names.")
324
+ try:
325
+ allowed_items = tuple(islice(iter(allowed_keys), _MAX_CONFIG_KEYS + 1))
326
+ except TypeError as exc:
327
+ raise ConfigurationError(
328
+ "allowed_keys must be an iterable of configuration names."
329
+ ) from exc
330
+ if not allowed_items or any(not isinstance(key, str) or not key for key in allowed_items):
331
+ raise ConfigurationError("allowed_keys must contain non-empty strings.")
332
+ if len(allowed_items) > _MAX_CONFIG_KEYS:
333
+ raise ConfigurationError(
334
+ "allowed_keys exceeds the configured limit.",
335
+ code="CONFIG_KEY_LIMIT",
336
+ context={"max_keys": _MAX_CONFIG_KEYS},
337
+ )
338
+ allowed = frozenset(allowed_items)
339
+ if not isinstance(env_prefix, str) or not env_prefix:
340
+ raise ConfigurationError("env_prefix must be non-empty.")
341
+ if format is not None and format not in ("json", "toml", "yaml"):
342
+ raise ConfigurationError(
343
+ "Configuration format must be json, toml, or yaml.",
344
+ code="UNSUPPORTED_CONFIG_FORMAT",
345
+ context={"format": format},
346
+ )
347
+ if environment is not None and not isinstance(environment, Mapping):
348
+ raise ConfigurationError(
349
+ "environment must be a mapping or None.", code="INVALID_CONFIG_ENVIRONMENT"
350
+ )
351
+ if cli is not None and not isinstance(cli, Mapping):
352
+ raise ConfigurationError("cli must be a mapping or None.", code="INVALID_CONFIG_CLI")
353
+ layers: list[ConfigLayer] = [_validated_layer("defaults", defaults, allowed_keys=allowed)]
354
+ if path is not None:
355
+ try:
356
+ config_path = Path(path)
357
+ except TypeError as exc:
358
+ raise ConfigurationError(
359
+ "path must be a filesystem path or None.", code="INVALID_CONFIG_PATH"
360
+ ) from exc
361
+ layers.append(
362
+ _validated_layer(
363
+ str(config_path), _read_file(config_path, format), allowed_keys=allowed
364
+ )
365
+ )
366
+ env_values = _environment_values(
367
+ os.environ if environment is None else environment,
368
+ prefix=env_prefix,
369
+ allowed_keys=allowed,
370
+ )
371
+ if env_values:
372
+ layers.append(_validated_layer("environment", env_values, allowed_keys=allowed))
373
+ if cli:
374
+ layers.append(_validated_layer("cli", cli, allowed_keys=allowed))
375
+ resolved: dict[str, object] = {}
376
+ for layer in layers:
377
+ resolved.update(layer.values)
378
+ return LoadedConfig(freeze_mapping(resolved), tuple(layers))
379
+
380
+
381
+ __all__ = ["ConfigLayer", "LoadedConfig", "load_config"]
@@ -0,0 +1,80 @@
1
+ """Lightweight immutable value objects shared across DNAKit."""
2
+
3
+ from dnakit.core.backend_info import BackendInfo
4
+ from dnakit.core.collection import DNASet, IDFactory
5
+ from dnakit.core.coordinates import (
6
+ CompoundLocation,
7
+ ExternalInterval,
8
+ Interval,
9
+ Location,
10
+ UnresolvedLocation,
11
+ export_location,
12
+ import_location,
13
+ reverse_strand_location,
14
+ )
15
+ from dnakit.core.enums import (
16
+ CoordinateSystem,
17
+ DNAAlphabet,
18
+ ExecutionMode,
19
+ GapKind,
20
+ ImplementationLabel,
21
+ IssueSeverity,
22
+ OriginClass,
23
+ Strand,
24
+ Strandedness,
25
+ Topology,
26
+ )
27
+ from dnakit.core.facade import DNA, FeatureInput
28
+ from dnakit.core.feature import DNAFeature
29
+ from dnakit.core.gap import Gap
30
+ from dnakit.core.issues import Issue
31
+ from dnakit.core.provenance import (
32
+ ArtifactRef,
33
+ Citation,
34
+ ImplementationInfo,
35
+ Provenance,
36
+ ReferenceInfo,
37
+ RunManifest,
38
+ )
39
+ from dnakit.core.record import DNARecord
40
+ from dnakit.core.results import MetricResult, Uncertainty
41
+ from dnakit.core.sequence import DNASequence
42
+
43
+ __all__ = [
44
+ "DNA",
45
+ "ArtifactRef",
46
+ "BackendInfo",
47
+ "Citation",
48
+ "CompoundLocation",
49
+ "CoordinateSystem",
50
+ "DNAAlphabet",
51
+ "DNAFeature",
52
+ "DNARecord",
53
+ "DNASequence",
54
+ "DNASet",
55
+ "ExecutionMode",
56
+ "ExternalInterval",
57
+ "FeatureInput",
58
+ "Gap",
59
+ "GapKind",
60
+ "IDFactory",
61
+ "ImplementationInfo",
62
+ "ImplementationLabel",
63
+ "Interval",
64
+ "Issue",
65
+ "IssueSeverity",
66
+ "Location",
67
+ "MetricResult",
68
+ "OriginClass",
69
+ "Provenance",
70
+ "ReferenceInfo",
71
+ "RunManifest",
72
+ "Strand",
73
+ "Strandedness",
74
+ "Topology",
75
+ "Uncertainty",
76
+ "UnresolvedLocation",
77
+ "export_location",
78
+ "import_location",
79
+ "reverse_strand_location",
80
+ ]