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/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"]
|
dnakit/config/loader.py
ADDED
|
@@ -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"]
|
dnakit/core/__init__.py
ADDED
|
@@ -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
|
+
]
|