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
@@ -0,0 +1,314 @@
1
+ """Dependency-free SVG renderers for advanced DNAKit result relationships."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+
7
+ from dnakit.alignment import AlignmentResult
8
+ from dnakit.core import (
9
+ CompoundLocation,
10
+ DNARecord,
11
+ DNASequence,
12
+ Interval,
13
+ Topology,
14
+ UnresolvedLocation,
15
+ )
16
+ from dnakit.core.facade import DNA, resolve_single_dna
17
+ from dnakit.exceptions import ConfigurationError
18
+
19
+ from ._svg import SVGBuilder, number
20
+ from .config import SVGTheme
21
+ from .results import SVGArtifact
22
+
23
+ _MAX_CANVAS_DIMENSION = 1_000_000
24
+ _MAX_MAP_PARTS = 100_000
25
+ _MAX_ALIGNMENT_COLUMNS = 100_000
26
+
27
+
28
+ def _bounded_integer(
29
+ value: object,
30
+ name: str,
31
+ *,
32
+ minimum: int = 1,
33
+ maximum: int,
34
+ ) -> int:
35
+ if isinstance(value, bool) or not isinstance(value, int) or not minimum <= value <= maximum:
36
+ raise ConfigurationError(f"{name} must be an integer in [{minimum}, {maximum}].")
37
+ return value
38
+
39
+
40
+ def _validate_text(value: object, name: str) -> str:
41
+ if not isinstance(value, str) or not value.strip():
42
+ raise ConfigurationError(f"{name} must be a non-empty string.")
43
+ if len(value) > 10_000:
44
+ raise ConfigurationError(
45
+ f"{name} exceeds the visualization text limit.",
46
+ code="VISUALIZATION_TEXT_LIMIT",
47
+ context={"name": name, "max_characters": 10_000},
48
+ )
49
+ return value
50
+
51
+
52
+ def _resolve_theme(theme: SVGTheme | None) -> SVGTheme:
53
+ if theme is None:
54
+ return SVGTheme()
55
+ if not isinstance(theme, SVGTheme):
56
+ raise ConfigurationError("theme must be SVGTheme or None.")
57
+ return theme
58
+
59
+
60
+ def _sequence_and_features(
61
+ value: DNA | DNASequence | DNARecord,
62
+ ) -> tuple[DNASequence, tuple[object, ...]]:
63
+ if isinstance(value, (DNA, DNARecord, DNASequence)):
64
+ sequence, record = resolve_single_dna(value)
65
+ return sequence, () if record is None else record.features
66
+ raise ConfigurationError("value must be DNASequence or DNARecord.")
67
+
68
+
69
+ def _parts(location: object) -> tuple[Interval, ...]:
70
+ if isinstance(location, Interval):
71
+ return (location,)
72
+ if isinstance(location, CompoundLocation):
73
+ return location.parts
74
+ if isinstance(location, UnresolvedLocation):
75
+ return ()
76
+ return ()
77
+
78
+
79
+ def _feature_parts(
80
+ features: tuple[object, ...], max_features: int
81
+ ) -> tuple[tuple[int, Interval], ...]:
82
+ if len(features) > max_features:
83
+ raise ConfigurationError("Feature count exceeds max_features.")
84
+ output: list[tuple[int, Interval]] = []
85
+ for index, feature in enumerate(features):
86
+ for part in _parts(getattr(feature, "location", None)):
87
+ if len(output) >= max_features:
88
+ raise ConfigurationError(
89
+ "Rendered feature-part count exceeds max_features.",
90
+ code="VISUALIZATION_FEATURE_PART_LIMIT",
91
+ )
92
+ output.append((index, part))
93
+ return tuple(output)
94
+
95
+
96
+ def plot_linear_map(
97
+ value: DNA | DNASequence | DNARecord,
98
+ *,
99
+ width: int = 900,
100
+ height: int = 220,
101
+ max_features: int = 10_000,
102
+ title: str = "Linear DNA map",
103
+ theme: SVGTheme | None = None,
104
+ ) -> SVGArtifact:
105
+ """Draw a resolved linear coordinate axis and feature intervals."""
106
+
107
+ _bounded_integer(width, "width", minimum=120, maximum=_MAX_CANVAS_DIMENSION)
108
+ _bounded_integer(height, "height", minimum=100, maximum=_MAX_CANVAS_DIMENSION)
109
+ _bounded_integer(max_features, "max_features", maximum=_MAX_MAP_PARTS)
110
+ _validate_text(title, "title")
111
+ sequence, features = _sequence_and_features(value)
112
+ if sequence.topology is not Topology.LINEAR or sequence.coordinate_span is None:
113
+ raise ConfigurationError("Linear map requires a resolved linear sequence.")
114
+ parts = _feature_parts(features, max_features)
115
+ resolved_theme = _resolve_theme(theme)
116
+ span = sequence.coordinate_span
117
+ builder = SVGBuilder(
118
+ width,
119
+ height,
120
+ title=title,
121
+ description="DNAKit linear DNA coordinate and feature map",
122
+ kind="linear-map",
123
+ theme=resolved_theme,
124
+ metadata={
125
+ "span": span,
126
+ "feature_count": len(features),
127
+ "rendered_part_count": len(parts),
128
+ "unresolved_feature_count": sum(
129
+ isinstance(getattr(feature, "location", None), UnresolvedLocation)
130
+ for feature in features
131
+ ),
132
+ },
133
+ )
134
+ left, right, axis_y = 60, width - 40, height // 2
135
+ builder.line(left, axis_y, right, axis_y, stroke=resolved_theme.foreground, stroke_width=3)
136
+ scale = (right - left) / max(1, span)
137
+ builder.text(left, axis_y + 28, 0, fill=resolved_theme.muted)
138
+ builder.text(right, axis_y + 28, span, fill=resolved_theme.muted, text_anchor="end")
139
+ for index, part in parts:
140
+ builder.rect(
141
+ left + part.start * scale,
142
+ axis_y - 18 - 18 * (index % 3),
143
+ max(1.0, len(part) * scale),
144
+ 12,
145
+ fill=resolved_theme.accent,
146
+ class_="feature",
147
+ data_feature_index=index,
148
+ data_start=part.start,
149
+ data_end=part.end,
150
+ )
151
+ return builder.artifact()
152
+
153
+
154
+ def plot_circular_map(
155
+ value: DNA | DNASequence | DNARecord,
156
+ *,
157
+ size: int = 520,
158
+ max_features: int = 10_000,
159
+ title: str = "Circular DNA map",
160
+ theme: SVGTheme | None = None,
161
+ ) -> SVGArtifact:
162
+ """Draw a resolved circular molecule and angular feature arcs."""
163
+
164
+ _bounded_integer(size, "size", minimum=100, maximum=_MAX_CANVAS_DIMENSION)
165
+ _bounded_integer(max_features, "max_features", maximum=_MAX_MAP_PARTS)
166
+ _validate_text(title, "title")
167
+ sequence, features = _sequence_and_features(value)
168
+ if sequence.topology is not Topology.CIRCULAR or sequence.coordinate_span is None:
169
+ raise ConfigurationError("Circular map requires a resolved circular sequence.")
170
+ parts = _feature_parts(features, max_features)
171
+ resolved_theme = _resolve_theme(theme)
172
+ span = sequence.coordinate_span
173
+ center, radius = size / 2, size * 0.34
174
+ builder = SVGBuilder(
175
+ size,
176
+ size,
177
+ title=title,
178
+ description="DNAKit circular DNA feature map",
179
+ kind="circular-map",
180
+ theme=resolved_theme,
181
+ metadata={
182
+ "span": span,
183
+ "feature_count": len(features),
184
+ "rendered_part_count": len(parts),
185
+ "unresolved_feature_count": sum(
186
+ isinstance(getattr(feature, "location", None), UnresolvedLocation)
187
+ for feature in features
188
+ ),
189
+ },
190
+ )
191
+ builder.element(
192
+ "circle",
193
+ cx=center,
194
+ cy=center,
195
+ r=radius,
196
+ fill="none",
197
+ stroke=resolved_theme.foreground,
198
+ stroke_width=4,
199
+ class_="backbone",
200
+ )
201
+ for index, part in parts:
202
+ start_angle = 2 * math.pi * part.start / span - math.pi / 2
203
+ x1 = center + radius * math.cos(start_angle)
204
+ y1 = center + radius * math.sin(start_angle)
205
+ if len(part) == span:
206
+ builder.element(
207
+ "circle",
208
+ cx=center,
209
+ cy=center,
210
+ r=radius,
211
+ fill="none",
212
+ stroke=resolved_theme.accent,
213
+ stroke_width=12,
214
+ class_="feature",
215
+ data_feature_index=index,
216
+ data_start=part.start,
217
+ data_end=part.end,
218
+ )
219
+ continue
220
+ if len(part) == 0:
221
+ builder.element(
222
+ "circle",
223
+ cx=x1,
224
+ cy=y1,
225
+ r=6,
226
+ fill=resolved_theme.accent,
227
+ class_="feature",
228
+ data_feature_index=index,
229
+ data_start=part.start,
230
+ data_end=part.end,
231
+ )
232
+ continue
233
+ end_angle = 2 * math.pi * part.end / span - math.pi / 2
234
+ x2, y2 = center + radius * math.cos(end_angle), center + radius * math.sin(end_angle)
235
+ large = int(len(part) > span / 2)
236
+ path = (
237
+ f"M {number(x1)} {number(y1)} A {number(radius)} {number(radius)} "
238
+ f"0 {large} 1 {number(x2)} {number(y2)}"
239
+ )
240
+ builder.element(
241
+ "path",
242
+ d=path,
243
+ fill="none",
244
+ stroke=resolved_theme.accent,
245
+ stroke_width=12,
246
+ class_="feature",
247
+ data_feature_index=index,
248
+ data_start=part.start,
249
+ data_end=part.end,
250
+ )
251
+ builder.text(center, center, f"{span} bp", text_anchor="middle", fill=resolved_theme.foreground)
252
+ return builder.artifact()
253
+
254
+
255
+ def plot_alignment(
256
+ result: AlignmentResult,
257
+ *,
258
+ columns_per_line: int = 80,
259
+ max_columns: int = 20_000,
260
+ theme: SVGTheme | None = None,
261
+ ) -> SVGArtifact:
262
+ """Render aligned strings in fixed-width blocks with a match line."""
263
+
264
+ if not isinstance(result, AlignmentResult):
265
+ raise ConfigurationError("result must be AlignmentResult.")
266
+ _bounded_integer(
267
+ max_columns,
268
+ "max_columns",
269
+ maximum=_MAX_ALIGNMENT_COLUMNS,
270
+ )
271
+ _bounded_integer(
272
+ columns_per_line,
273
+ "columns_per_line",
274
+ maximum=max_columns,
275
+ )
276
+ if result.alignment_length > max_columns:
277
+ raise ConfigurationError("Alignment exceeds max_columns.")
278
+ resolved_theme = _resolve_theme(theme)
279
+ blocks = max(1, math.ceil(result.alignment_length / columns_per_line))
280
+ width, height = max(640, columns_per_line * 10 + 100), 70 + blocks * 72
281
+ builder = SVGBuilder(
282
+ width,
283
+ height,
284
+ title="DNA alignment",
285
+ description="DNAKit pairwise alignment",
286
+ kind="alignment",
287
+ theme=resolved_theme,
288
+ metadata={
289
+ "mode": result.method,
290
+ "score": result.score,
291
+ "columns": result.alignment_length,
292
+ },
293
+ )
294
+ for block in range(blocks):
295
+ start = block * columns_per_line
296
+ end = min(result.alignment_length, start + columns_per_line)
297
+ query = result.aligned_query[start:end]
298
+ target = result.aligned_target[start:end]
299
+ matches = "".join(
300
+ "|" if left == right and left != "-" else " "
301
+ for left, right in zip(query, target, strict=True)
302
+ )
303
+ y = 50 + block * 72
304
+ builder.text(24, y, query, class_="aligned-query", fill=resolved_theme.foreground)
305
+ builder.text(24, y + 20, matches, class_="match-line", fill=resolved_theme.accent)
306
+ builder.text(24, y + 40, target, class_="aligned-target", fill=resolved_theme.foreground)
307
+ return builder.artifact()
308
+
309
+
310
+ __all__ = [
311
+ "plot_alignment",
312
+ "plot_circular_map",
313
+ "plot_linear_map",
314
+ ]
@@ -0,0 +1,255 @@
1
+ """Validated configuration for dependency-free SVG visualizations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import re
7
+ from dataclasses import dataclass, field
8
+ from enum import Enum
9
+
10
+ from dnakit.exceptions import ConfigurationError
11
+
12
+ _COLOR_PATTERN = re.compile(r"^(?:#[0-9A-Fa-f]{3,8}|[A-Za-z][A-Za-z0-9_-]*)$")
13
+ _FONT_PATTERN = re.compile(r"^[A-Za-z0-9 _,-]+$")
14
+
15
+
16
+ class LimitPolicy(str, Enum):
17
+ """Explicit behavior when a plot exceeds its configured display limit."""
18
+
19
+ ERROR = "error"
20
+ TRUNCATE = "truncate"
21
+ STRIDE = "stride"
22
+
23
+
24
+ def _positive_int(value: int, name: str) -> None:
25
+ if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
26
+ raise ConfigurationError(
27
+ f"{name} must be a positive integer.",
28
+ code="INVALID_VISUALIZATION_CONFIG",
29
+ context={name: value},
30
+ )
31
+
32
+
33
+ def _non_negative_int(value: int, name: str) -> None:
34
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0:
35
+ raise ConfigurationError(
36
+ f"{name} must be a non-negative integer.",
37
+ code="INVALID_VISUALIZATION_CONFIG",
38
+ context={name: value},
39
+ )
40
+
41
+
42
+ def _finite_optional(value: float | None, name: str) -> None:
43
+ if value is not None and (
44
+ isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value)
45
+ ):
46
+ raise ConfigurationError(
47
+ f"{name} must be a finite number or None.",
48
+ code="INVALID_VISUALIZATION_CONFIG",
49
+ context={name: value},
50
+ )
51
+
52
+
53
+ def _color(value: str, name: str) -> None:
54
+ if not isinstance(value, str) or not _COLOR_PATTERN.fullmatch(value):
55
+ raise ConfigurationError(
56
+ f"{name} must be a simple CSS color name or hexadecimal color.",
57
+ code="INVALID_VISUALIZATION_COLOR",
58
+ context={name: value},
59
+ )
60
+
61
+
62
+ def _title(value: str | None, name: str = "title") -> None:
63
+ if value is not None and (not isinstance(value, str) or not value.strip()):
64
+ raise ConfigurationError(
65
+ f"{name} must be a non-empty string or None.",
66
+ code="INVALID_VISUALIZATION_CONFIG",
67
+ )
68
+
69
+
70
+ def _coerce_policy(value: LimitPolicy | str, *, allowed: set[LimitPolicy]) -> LimitPolicy:
71
+ try:
72
+ policy = value if isinstance(value, LimitPolicy) else LimitPolicy(value)
73
+ except (TypeError, ValueError) as exc:
74
+ raise ConfigurationError(
75
+ "Unknown visualization limit policy.",
76
+ code="INVALID_VISUALIZATION_LIMIT_POLICY",
77
+ context={"policy": value},
78
+ ) from exc
79
+ if policy not in allowed:
80
+ raise ConfigurationError(
81
+ "The selected limit policy is not valid for this plot type.",
82
+ code="INVALID_VISUALIZATION_LIMIT_POLICY",
83
+ context={"policy": policy.value, "allowed": sorted(item.value for item in allowed)},
84
+ )
85
+ return policy
86
+
87
+
88
+ @dataclass(frozen=True, slots=True)
89
+ class SVGTheme:
90
+ """Shared colors and typography embedded directly in an SVG artifact."""
91
+
92
+ background: str = "#ffffff"
93
+ foreground: str = "#1f2937"
94
+ muted: str = "#6b7280"
95
+ grid: str = "#d1d5db"
96
+ accent: str = "#2563eb"
97
+ missing: str = "#e5e7eb"
98
+ font_family: str = "monospace"
99
+
100
+ def __post_init__(self) -> None:
101
+ for name in ("background", "foreground", "muted", "grid", "accent", "missing"):
102
+ _color(getattr(self, name), name)
103
+ if not isinstance(self.font_family, str) or not _FONT_PATTERN.fullmatch(self.font_family):
104
+ raise ConfigurationError(
105
+ "font_family contains unsupported characters.",
106
+ code="INVALID_VISUALIZATION_FONT",
107
+ context={"font_family": self.font_family},
108
+ )
109
+
110
+
111
+ @dataclass(frozen=True, slots=True)
112
+ class SequencePlotConfig:
113
+ """Layout and safety limits for the gap-aware sequence text plot."""
114
+
115
+ bases_per_line: int = 60
116
+ max_symbols: int = 10_000
117
+ max_gaps: int = 1_000
118
+ limit_policy: LimitPolicy = LimitPolicy.ERROR
119
+ start_coordinate: int = 0
120
+ show_coordinates: bool = True
121
+ show_complement: bool = False
122
+ font_size: int = 14
123
+ cell_width: int = 12
124
+ line_height: int = 62
125
+ margin: int = 24
126
+ title: str | None = None
127
+ theme: SVGTheme = field(default_factory=SVGTheme)
128
+
129
+ def __post_init__(self) -> None:
130
+ for name in (
131
+ "bases_per_line",
132
+ "max_symbols",
133
+ "max_gaps",
134
+ "font_size",
135
+ "cell_width",
136
+ "line_height",
137
+ "margin",
138
+ ):
139
+ _positive_int(getattr(self, name), name)
140
+ _non_negative_int(self.start_coordinate, "start_coordinate")
141
+ for name in ("show_coordinates", "show_complement"):
142
+ if not isinstance(getattr(self, name), bool):
143
+ raise ConfigurationError(
144
+ f"{name} must be a boolean.", code="INVALID_VISUALIZATION_CONFIG"
145
+ )
146
+ _title(self.title)
147
+ if not isinstance(self.theme, SVGTheme):
148
+ raise ConfigurationError("theme must be SVGTheme.")
149
+ object.__setattr__(
150
+ self,
151
+ "limit_policy",
152
+ _coerce_policy(self.limit_policy, allowed={LimitPolicy.ERROR, LimitPolicy.TRUNCATE}),
153
+ )
154
+
155
+
156
+ @dataclass(frozen=True, slots=True)
157
+ class HeatmapConfig:
158
+ """Limits and color scale for matrix and fingerprint heatmaps."""
159
+
160
+ cell_size: int = 18
161
+ max_rows: int = 200
162
+ max_columns: int = 256
163
+ limit_policy: LimitPolicy = LimitPolicy.ERROR
164
+ value_min: float | None = None
165
+ value_max: float | None = None
166
+ low_color: str = "#f7fbff"
167
+ high_color: str = "#08306b"
168
+ show_values: bool = False
169
+ show_labels: bool = True
170
+ title: str | None = None
171
+ theme: SVGTheme = field(default_factory=SVGTheme)
172
+
173
+ def __post_init__(self) -> None:
174
+ for name in ("cell_size", "max_rows", "max_columns"):
175
+ _positive_int(getattr(self, name), name)
176
+ _finite_optional(self.value_min, "value_min")
177
+ _finite_optional(self.value_max, "value_max")
178
+ if (
179
+ self.value_min is not None
180
+ and self.value_max is not None
181
+ and self.value_min >= self.value_max
182
+ ):
183
+ raise ConfigurationError(
184
+ "value_min must be smaller than value_max.",
185
+ code="INVALID_VISUALIZATION_CONFIG",
186
+ )
187
+ for name in ("low_color", "high_color"):
188
+ _color(getattr(self, name), name)
189
+ if not re.fullmatch(r"#(?:[0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})", getattr(self, name)):
190
+ raise ConfigurationError(
191
+ f"{name} must use #RGB or #RRGGBB for interpolation.",
192
+ code="INVALID_VISUALIZATION_COLOR",
193
+ )
194
+ for name in ("show_values", "show_labels"):
195
+ if not isinstance(getattr(self, name), bool):
196
+ raise ConfigurationError(f"{name} must be a boolean.")
197
+ _title(self.title)
198
+ if not isinstance(self.theme, SVGTheme):
199
+ raise ConfigurationError("theme must be SVGTheme.")
200
+ object.__setattr__(
201
+ self,
202
+ "limit_policy",
203
+ _coerce_policy(self.limit_policy, allowed={LimitPolicy.ERROR, LimitPolicy.STRIDE}),
204
+ )
205
+
206
+
207
+ @dataclass(frozen=True, slots=True)
208
+ class SaveConfig:
209
+ """Filesystem policy for deterministic SVG persistence."""
210
+
211
+ overwrite: bool = False
212
+ create_parents: bool = False
213
+
214
+ def __post_init__(self) -> None:
215
+ for name in ("overwrite", "create_parents"):
216
+ if not isinstance(getattr(self, name), bool):
217
+ raise ConfigurationError(
218
+ f"{name} must be a boolean.", code="INVALID_VISUALIZATION_CONFIG"
219
+ )
220
+
221
+
222
+ @dataclass(frozen=True, slots=True)
223
+ class ImageExportConfig:
224
+ """Safe raster/PDF export settings for the optional graphics backend."""
225
+
226
+ dpi: int = 600
227
+ width: int | None = None
228
+ height: int | None = None
229
+ transparent: bool = False
230
+ max_output_pixels: int = 100_000_000
231
+ overwrite: bool = False
232
+ create_parents: bool = False
233
+
234
+ def __post_init__(self) -> None:
235
+ _positive_int(self.dpi, "dpi")
236
+ _positive_int(self.max_output_pixels, "max_output_pixels")
237
+ for name in ("width", "height"):
238
+ value = getattr(self, name)
239
+ if value is not None:
240
+ _positive_int(value, name)
241
+ for name in ("transparent", "overwrite", "create_parents"):
242
+ if not isinstance(getattr(self, name), bool):
243
+ raise ConfigurationError(
244
+ f"{name} must be a boolean.", code="INVALID_VISUALIZATION_CONFIG"
245
+ )
246
+
247
+
248
+ __all__ = [
249
+ "HeatmapConfig",
250
+ "ImageExportConfig",
251
+ "LimitPolicy",
252
+ "SVGTheme",
253
+ "SaveConfig",
254
+ "SequencePlotConfig",
255
+ ]