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,431 @@
1
+ """Self-contained bounded HTML reports for local, read-only exploration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html
6
+ import json
7
+ from collections.abc import Iterable, Mapping
8
+ from itertools import islice
9
+
10
+ from dnakit.core import DNARecord, DNASet
11
+ from dnakit.core._json import to_json_compatible
12
+ from dnakit.exceptions import ConfigurationError
13
+
14
+ from .results import HTMLReportArtifact
15
+
16
+ _STYLE = """
17
+ body{font-family:system-ui,sans-serif;max-width:1100px;margin:2rem auto;padding:0 1rem;
18
+ color:#172033}
19
+ table{border-collapse:collapse;width:100%}th,td{border:1px solid #d8dee9;padding:.45rem;
20
+ text-align:left}
21
+ code,pre{font-family:ui-monospace,monospace;background:#f5f7fa;padding:.15rem .3rem;overflow:auto}
22
+ details{margin:1rem 0;padding:.6rem;border:1px solid #d8dee9;border-radius:.4rem}
23
+ input{padding:.5rem;width:min(30rem,90%)}.muted{color:#5b6472}.hidden{display:none}
24
+ """.strip()
25
+ _SCRIPT = """
26
+ const q=document.getElementById('filter');
27
+ q.addEventListener('input',()=>{const v=q.value.toLowerCase();
28
+ document.querySelectorAll('tbody tr').forEach(
29
+ r=>r.classList.toggle('hidden',!r.dataset.search.includes(v)));});
30
+ """.strip()
31
+
32
+ _DEFAULT_MAX_TOTAL_SEQUENCE_SYMBOLS = 10_000_000
33
+ _DEFAULT_MAX_TOTAL_RECORD_TEXT_CHARACTERS = 5_000_000
34
+ _DEFAULT_MAX_OUTPUT_BYTES = 50_000_000
35
+ _DEFAULT_MAX_RESULT_DEPTH = 64
36
+ _DEFAULT_MAX_RESULT_NODES = 1_000_000
37
+
38
+
39
+ def _positive_integer(name: str, value: object) -> int:
40
+ if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
41
+ raise ConfigurationError(f"{name} must be a positive integer.")
42
+ return value
43
+
44
+
45
+ class _BoundedHTMLBuilder:
46
+ """Accumulate HTML fragments without ever accepting an oversized payload."""
47
+
48
+ def __init__(self, max_output_bytes: int) -> None:
49
+ self._limit = max_output_bytes
50
+ self._byte_count = 0
51
+ self._parts: list[str] = []
52
+
53
+ @property
54
+ def remaining_bytes(self) -> int:
55
+ return self._limit - self._byte_count
56
+
57
+ def require_capacity(self, byte_count: int) -> None:
58
+ if byte_count > self.remaining_bytes:
59
+ raise ConfigurationError(
60
+ "HTML report exceeds max_output_bytes.",
61
+ code="HTML_REPORT_OUTPUT_LIMIT",
62
+ context={
63
+ "max_output_bytes": self._limit,
64
+ "bytes_before_fragment": self._byte_count,
65
+ "minimum_fragment_bytes": byte_count,
66
+ },
67
+ )
68
+
69
+ def append(self, fragment: str) -> None:
70
+ encoded_size = len(fragment.encode("utf-8"))
71
+ self.require_capacity(encoded_size)
72
+ self._parts.append(fragment)
73
+ self._byte_count += encoded_size
74
+
75
+ def finish(self) -> str:
76
+ return "".join(self._parts)
77
+
78
+
79
+ def _escaped_utf8_size(value: str, *, quote: bool = True) -> int:
80
+ """Return the exact UTF-8 size produced by :func:`html.escape`."""
81
+
82
+ size = len(value.encode("utf-8"))
83
+ replacements = {"&": "&amp;", "<": "&lt;", ">": "&gt;"}
84
+ if quote:
85
+ replacements.update({'"': "&quot;", "'": "&#x27;"})
86
+ for source, replacement in replacements.items():
87
+ size += value.count(source) * (len(replacement) - 1)
88
+ return size
89
+
90
+
91
+ def _display_sequence(record: DNARecord, *, max_bytes: int) -> str:
92
+ pieces: list[str] = []
93
+ byte_count = 0
94
+ for part in record.sequence.parts:
95
+ if isinstance(part, str):
96
+ piece = part
97
+ else:
98
+ length = "?" if part.length is None else str(part.length)
99
+ piece = f"<gap length={length} kind={part.kind.value}>"
100
+ byte_count += len(piece)
101
+ if byte_count > max_bytes:
102
+ raise ConfigurationError(
103
+ "HTML report exceeds max_output_bytes.",
104
+ code="HTML_REPORT_OUTPUT_LIMIT",
105
+ context={
106
+ "available_output_bytes": max_bytes,
107
+ "minimum_sequence_display_bytes": byte_count,
108
+ },
109
+ )
110
+ pieces.append(piece)
111
+ return "".join(pieces)
112
+
113
+
114
+ def _bounded_records(
115
+ records: DNASet | Iterable[DNARecord],
116
+ max_records: int,
117
+ max_total_sequence_symbols: int,
118
+ max_total_record_text_characters: int,
119
+ ) -> tuple[DNARecord, ...]:
120
+ if isinstance(records, DNASet):
121
+ iterator = iter(records)
122
+ else:
123
+ try:
124
+ iterator = iter(records)
125
+ except TypeError as exc:
126
+ raise ConfigurationError("records must be DNASet or an iterable of DNARecord.") from exc
127
+ collected: list[DNARecord] = []
128
+ sequence_symbol_count = 0
129
+ record_text_characters = 0
130
+ for index, record in enumerate(islice(iterator, max_records + 1)):
131
+ if index >= max_records:
132
+ raise ConfigurationError(
133
+ "HTML report exceeds max_records.",
134
+ code="HTML_REPORT_RECORD_LIMIT",
135
+ context={"max_records": max_records},
136
+ )
137
+ if not isinstance(record, DNARecord):
138
+ raise ConfigurationError(
139
+ "records must contain only DNARecord objects.",
140
+ context={"index": index, "type": type(record).__name__},
141
+ )
142
+ collected.append(record)
143
+ sequence_symbol_count += record.sequence.symbol_length
144
+ if sequence_symbol_count > max_total_sequence_symbols:
145
+ raise ConfigurationError(
146
+ "HTML report records exceed max_total_sequence_symbols.",
147
+ code="HTML_REPORT_SEQUENCE_LIMIT",
148
+ context={
149
+ "max_total_sequence_symbols": max_total_sequence_symbols,
150
+ "total_sequence_symbols": sequence_symbol_count,
151
+ "record_index": index,
152
+ },
153
+ )
154
+ record_text_characters += len(record.id) + len(record.description)
155
+ if record_text_characters > max_total_record_text_characters:
156
+ raise ConfigurationError(
157
+ "HTML report record text exceeds max_total_record_text_characters.",
158
+ code="HTML_REPORT_RECORD_TEXT_LIMIT",
159
+ context={
160
+ "max_total_record_text_characters": max_total_record_text_characters,
161
+ "total_record_text_characters": record_text_characters,
162
+ "record_index": index,
163
+ },
164
+ )
165
+ return tuple(collected)
166
+
167
+
168
+ def _validate_result_shape(value: object, max_depth: int, max_nodes: int) -> None:
169
+ nodes = [0]
170
+ active: set[int] = set()
171
+
172
+ def visit(item: object, depth: int) -> None:
173
+ nodes[0] += 1
174
+ if depth > max_depth or nodes[0] > max_nodes:
175
+ raise ConfigurationError(
176
+ "HTML report results exceed structural limits.",
177
+ code="HTML_REPORT_RESULT_STRUCTURE_LIMIT",
178
+ context={"max_result_depth": max_depth, "max_result_nodes": max_nodes},
179
+ )
180
+ if isinstance(item, (Mapping, list, tuple)):
181
+ identity = id(item)
182
+ if identity in active:
183
+ raise ConfigurationError(
184
+ "HTML report results contain a recursive object.",
185
+ code="HTML_REPORT_RESULT_STRUCTURE_LIMIT",
186
+ )
187
+ active.add(identity)
188
+ try:
189
+ if isinstance(item, Mapping):
190
+ for key in item:
191
+ visit(key, depth + 1)
192
+ visit(item[key], depth + 1)
193
+ else:
194
+ for child in item:
195
+ visit(child, depth + 1)
196
+ finally:
197
+ active.remove(identity)
198
+
199
+ visit(value, 0)
200
+
201
+
202
+ def _json_string_lower_bound(value: str) -> int:
203
+ size = 2
204
+ for character in value:
205
+ codepoint = ord(character)
206
+ if character in {'"', "\\", "\b", "\f", "\n", "\r", "\t"}:
207
+ size += 2
208
+ elif codepoint < 0x20:
209
+ size += 6
210
+ else:
211
+ size += len(character.encode("utf-8"))
212
+ return size
213
+
214
+
215
+ def _require_json_lower_bound(value: object, max_bytes: int) -> None:
216
+ """Reject oversized JSON before conversion or encoder chunk allocation."""
217
+
218
+ total = [0]
219
+
220
+ def add(amount: int) -> None:
221
+ total[0] += amount
222
+ if total[0] > max_bytes:
223
+ raise ConfigurationError(
224
+ "HTML report results exceed max_result_bytes.",
225
+ code="HTML_REPORT_RESULT_LIMIT",
226
+ context={"max_result_bytes": max_bytes},
227
+ )
228
+
229
+ def visit(item: object) -> None:
230
+ if isinstance(item, str):
231
+ add(_json_string_lower_bound(item))
232
+ elif item is None or item is True:
233
+ add(4)
234
+ elif item is False:
235
+ add(5)
236
+ elif isinstance(item, (int, float)):
237
+ add(len(str(item).encode("utf-8")))
238
+ elif isinstance(item, Mapping):
239
+ add(2)
240
+ first = True
241
+ for key in item:
242
+ if not first:
243
+ add(1)
244
+ first = False
245
+ visit(str(key))
246
+ add(1)
247
+ visit(item[key])
248
+ elif isinstance(item, (list, tuple)):
249
+ add(2)
250
+ first = True
251
+ for child in item:
252
+ if not first:
253
+ add(1)
254
+ first = False
255
+ visit(child)
256
+
257
+ visit(value)
258
+
259
+
260
+ def _bounded_json(value: object, max_bytes: int, *, result_name: str) -> str:
261
+ """Encode JSON incrementally while retaining at most ``max_bytes`` bytes."""
262
+
263
+ _require_json_lower_bound(value, max_bytes)
264
+ encoder = json.JSONEncoder(
265
+ ensure_ascii=False,
266
+ sort_keys=True,
267
+ indent=2,
268
+ allow_nan=False,
269
+ )
270
+ pieces: list[str] = []
271
+ total = 0
272
+ try:
273
+ for piece in encoder.iterencode(to_json_compatible(value)):
274
+ total += len(piece.encode("utf-8"))
275
+ if total > max_bytes:
276
+ raise ConfigurationError(
277
+ "HTML report results exceed max_result_bytes.",
278
+ code="HTML_REPORT_RESULT_LIMIT",
279
+ context={"max_result_bytes": max_bytes, "result_name": result_name},
280
+ )
281
+ pieces.append(piece)
282
+ except ConfigurationError:
283
+ raise
284
+ except (TypeError, ValueError, RecursionError) as exc:
285
+ raise ConfigurationError(
286
+ "A report result is not JSON-compatible.", context={"result_name": result_name}
287
+ ) from exc
288
+ return "".join(pieces)
289
+
290
+
291
+ def _result_payload(
292
+ results: Mapping[str, object],
293
+ max_result_bytes: int,
294
+ max_result_depth: int,
295
+ max_result_nodes: int,
296
+ ) -> tuple[tuple[str, str], ...]:
297
+ if not isinstance(results, Mapping):
298
+ raise ConfigurationError("results must be a mapping.")
299
+ if (
300
+ isinstance(max_result_bytes, bool)
301
+ or not isinstance(max_result_bytes, int)
302
+ or max_result_bytes <= 0
303
+ ):
304
+ raise ConfigurationError("max_result_bytes must be a positive integer.")
305
+ payloads: list[tuple[str, str]] = []
306
+ total = 0
307
+ if any(not isinstance(name, str) or not name.strip() for name in results):
308
+ raise ConfigurationError("result names must be non-empty strings.")
309
+ for name in sorted(results):
310
+ value = results[name]
311
+ converter = getattr(value, "to_dict", None)
312
+ if callable(converter):
313
+ value = converter()
314
+ _validate_result_shape(value, max_result_depth, max_result_nodes)
315
+ remaining = max_result_bytes - total
316
+ encoded = _bounded_json(value, remaining, result_name=name)
317
+ total += len(encoded.encode("utf-8"))
318
+ payloads.append((name, encoded))
319
+ return tuple(payloads)
320
+
321
+
322
+ def build_html_report(
323
+ records: DNASet | Iterable[DNARecord],
324
+ *,
325
+ results: Mapping[str, object] | None = None,
326
+ title: str = "DNAKit report",
327
+ max_records: int = 1_000,
328
+ max_result_bytes: int = 5_000_000,
329
+ max_total_sequence_symbols: int = _DEFAULT_MAX_TOTAL_SEQUENCE_SYMBOLS,
330
+ max_total_record_text_characters: int = _DEFAULT_MAX_TOTAL_RECORD_TEXT_CHARACTERS,
331
+ max_output_bytes: int = _DEFAULT_MAX_OUTPUT_BYTES,
332
+ max_result_depth: int = _DEFAULT_MAX_RESULT_DEPTH,
333
+ max_result_nodes: int = _DEFAULT_MAX_RESULT_NODES,
334
+ ) -> HTMLReportArtifact:
335
+ """Build a self-contained searchable and expandable local HTML report.
336
+
337
+ ``max_total_sequence_symbols`` limits the cumulative nucleotide symbols
338
+ across all records (explicit gap spans are excluded). The cumulative
339
+ number of Python characters in record IDs and descriptions is limited by
340
+ ``max_total_record_text_characters``. ``max_output_bytes`` is an exact
341
+ upper bound on the final UTF-8 encoded HTML document. Every limit must be
342
+ a positive integer.
343
+ """
344
+
345
+ if not isinstance(title, str) or not title.strip():
346
+ raise ConfigurationError("title must be a non-empty string.")
347
+ resolved_max_records = _positive_integer("max_records", max_records)
348
+ resolved_max_result_bytes = _positive_integer("max_result_bytes", max_result_bytes)
349
+ resolved_max_total_sequence_symbols = _positive_integer(
350
+ "max_total_sequence_symbols", max_total_sequence_symbols
351
+ )
352
+ resolved_max_total_record_text_characters = _positive_integer(
353
+ "max_total_record_text_characters", max_total_record_text_characters
354
+ )
355
+ resolved_max_output_bytes = _positive_integer("max_output_bytes", max_output_bytes)
356
+ resolved_max_result_depth = _positive_integer("max_result_depth", max_result_depth)
357
+ resolved_max_result_nodes = _positive_integer("max_result_nodes", max_result_nodes)
358
+ materialized = _bounded_records(
359
+ records,
360
+ resolved_max_records,
361
+ resolved_max_total_sequence_symbols,
362
+ resolved_max_total_record_text_characters,
363
+ )
364
+ resolved_results: Mapping[str, object] = {} if results is None else results
365
+ builder = _BoundedHTMLBuilder(resolved_max_output_bytes)
366
+ builder.append('<!doctype html><html lang="zh-CN"><head><meta charset="utf-8">')
367
+ builder.append('<meta name="viewport" content="width=device-width,initial-scale=1">')
368
+ builder.append(
369
+ '<meta http-equiv="Content-Security-Policy" '
370
+ "content=\"default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'\">"
371
+ )
372
+ escaped_title_size = _escaped_utf8_size(title)
373
+ builder.require_capacity(escaped_title_size * 2)
374
+ escaped_title = html.escape(title)
375
+ builder.append(f"<title>{escaped_title}</title><style>{_STYLE}</style></head><body>")
376
+ builder.append(f'<h1>{escaped_title}</h1><p class="muted">本报告完全自包含, 不连接网络。</p>')
377
+ builder.append(
378
+ '<label for="filter">筛选记录:</label> <input id="filter" type="search" '
379
+ 'placeholder="ID、描述或序列">'
380
+ )
381
+ builder.append(
382
+ "<table><thead><tr><th>ID</th><th>描述</th><th>符号长度</th><th>序列</th>"
383
+ "</tr></thead><tbody>"
384
+ )
385
+ for record in materialized:
386
+ sequence = _display_sequence(record, max_bytes=builder.remaining_bytes)
387
+ minimum_search_bytes = len(record.id) + len(record.description) + len(sequence) + 2
388
+ builder.require_capacity(minimum_search_bytes + len(sequence))
389
+ search_source = f"{record.id} {record.description} {sequence}".lower()
390
+ row_size = (
391
+ len(
392
+ '<tr data-search=""><td><code></code></td><td></td><td></td>'
393
+ "<td><code></code></td></tr>"
394
+ )
395
+ + _escaped_utf8_size(search_source)
396
+ + _escaped_utf8_size(record.id)
397
+ + _escaped_utf8_size(record.description)
398
+ + len(str(record.sequence.symbol_length))
399
+ + _escaped_utf8_size(sequence)
400
+ )
401
+ builder.require_capacity(row_size)
402
+ builder.append(
403
+ f'<tr data-search="{html.escape(search_source)}"><td><code>'
404
+ f"{html.escape(record.id)}</code></td><td>{html.escape(record.description)}</td>"
405
+ f"<td>{record.sequence.symbol_length}</td><td><code>{html.escape(sequence)}</code>"
406
+ "</td></tr>"
407
+ )
408
+ builder.append("</tbody></table><h2>分析结果</h2>")
409
+ result_payloads = _result_payload(
410
+ resolved_results,
411
+ resolved_max_result_bytes,
412
+ resolved_max_result_depth,
413
+ resolved_max_result_nodes,
414
+ )
415
+ for name, encoded in result_payloads:
416
+ block_size = (
417
+ len("<details><summary></summary><pre></pre></details>")
418
+ + _escaped_utf8_size(name)
419
+ + _escaped_utf8_size(encoded)
420
+ )
421
+ builder.require_capacity(block_size)
422
+ builder.append(
423
+ f"<details><summary>{html.escape(name)}</summary>"
424
+ f"<pre>{html.escape(encoded)}</pre></details>"
425
+ )
426
+ builder.append(f"<script>{_SCRIPT}</script></body></html>")
427
+ document = builder.finish()
428
+ return HTMLReportArtifact(document, len(materialized), tuple(sorted(resolved_results)))
429
+
430
+
431
+ __all__ = ["build_html_report"]
@@ -0,0 +1,236 @@
1
+ """Immutable SVG and persistence result objects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import xml.etree.ElementTree as ET
7
+ from collections.abc import Iterable, Mapping
8
+ from dataclasses import dataclass
9
+ from typing import Any, cast
10
+
11
+ from dnakit.core._json import FrozenDict, freeze_mapping, to_json_compatible
12
+ from dnakit.core.issues import Issue
13
+ from dnakit.core.provenance import ArtifactRef, Provenance
14
+ from dnakit.exceptions import ConfigurationError
15
+
16
+ MAX_SVG_INPUT_BYTES = 50_000_000
17
+ MAX_SVG_ELEMENTS = 1_000_000
18
+
19
+
20
+ @dataclass(frozen=True, init=False)
21
+ class SVGArtifact:
22
+ """A deterministic, standalone SVG document and its audit metadata."""
23
+
24
+ kind: str
25
+ svg: str
26
+ width: int
27
+ height: int
28
+ element_count: int
29
+ metadata: FrozenDict
30
+ provenance: Provenance
31
+ issues: tuple[Issue, ...]
32
+
33
+ def __init__(
34
+ self,
35
+ kind: str,
36
+ svg: str,
37
+ width: int,
38
+ height: int,
39
+ element_count: int,
40
+ *,
41
+ metadata: Mapping[str, object] | None = None,
42
+ provenance: Provenance | None = None,
43
+ issues: Iterable[Issue] = (),
44
+ ) -> None:
45
+ for name, text_value in (("kind", kind), ("svg", svg)):
46
+ if not isinstance(text_value, str) or not text_value.strip():
47
+ raise ConfigurationError(f"SVGArtifact {name} must be non-empty.")
48
+ byte_count = len(svg.encode("utf-8"))
49
+ if byte_count > MAX_SVG_INPUT_BYTES:
50
+ raise ConfigurationError(
51
+ "SVGArtifact exceeds the SVG byte limit.",
52
+ code="SVG_INPUT_SIZE_LIMIT",
53
+ context={"max_svg_input_bytes": MAX_SVG_INPUT_BYTES},
54
+ )
55
+ if "<!DOCTYPE" in svg.upper() or "<!ENTITY" in svg.upper():
56
+ raise ConfigurationError(
57
+ "SVGArtifact must not contain document type or entity declarations.",
58
+ code="UNSAFE_SVG_INPUT",
59
+ )
60
+ try:
61
+ root = ET.fromstring(svg)
62
+ except (ET.ParseError, RecursionError) as exc:
63
+ raise ConfigurationError(
64
+ "SVGArtifact svg must be valid XML with one SVG root.",
65
+ code="INVALID_SVG_INPUT",
66
+ ) from exc
67
+ if root.tag.rsplit("}", 1)[-1] != "svg":
68
+ raise ConfigurationError(
69
+ "SVGArtifact root element must be SVG.", code="INVALID_SVG_INPUT"
70
+ )
71
+ actual_element_count = 0
72
+ for actual_element_count, _element in enumerate(root.iter(), start=1):
73
+ if actual_element_count > MAX_SVG_ELEMENTS:
74
+ raise ConfigurationError(
75
+ "SVGArtifact exceeds the SVG element limit.",
76
+ code="SVG_ELEMENT_LIMIT",
77
+ context={"max_svg_elements": MAX_SVG_ELEMENTS},
78
+ )
79
+ for name, integer_value in (
80
+ ("width", width),
81
+ ("height", height),
82
+ ("element_count", element_count),
83
+ ):
84
+ if (
85
+ isinstance(integer_value, bool)
86
+ or not isinstance(integer_value, int)
87
+ or integer_value <= 0
88
+ ):
89
+ raise ConfigurationError(f"SVGArtifact {name} must be a positive integer.")
90
+ if element_count != actual_element_count:
91
+ raise ConfigurationError(
92
+ "SVGArtifact element_count does not match the parsed SVG.",
93
+ code="SVG_ELEMENT_COUNT_MISMATCH",
94
+ context={"declared": element_count, "actual": actual_element_count},
95
+ )
96
+ if provenance is not None and not isinstance(provenance, Provenance):
97
+ raise ConfigurationError("SVGArtifact provenance must be Provenance or None.")
98
+ issue_tuple = tuple(issues)
99
+ if any(not isinstance(issue, Issue) for issue in issue_tuple):
100
+ raise ConfigurationError("SVGArtifact issues must contain Issue objects.")
101
+ object.__setattr__(self, "kind", kind)
102
+ object.__setattr__(self, "svg", svg)
103
+ object.__setattr__(self, "width", width)
104
+ object.__setattr__(self, "height", height)
105
+ object.__setattr__(self, "element_count", element_count)
106
+ object.__setattr__(self, "metadata", freeze_mapping(metadata))
107
+ object.__setattr__(
108
+ self, "provenance", provenance if provenance is not None else Provenance()
109
+ )
110
+ object.__setattr__(self, "issues", issue_tuple)
111
+
112
+ @property
113
+ def sha256(self) -> str:
114
+ """SHA-256 of the exact UTF-8 SVG payload."""
115
+
116
+ return hashlib.sha256(self.svg.encode("utf-8")).hexdigest()
117
+
118
+ def to_dict(self) -> dict[str, Any]:
119
+ return cast(dict[str, Any], to_json_compatible(self))
120
+
121
+
122
+ @dataclass(frozen=True, init=False)
123
+ class VisualizationSaveResult:
124
+ """Audit record for a successfully persisted visualization artifact."""
125
+
126
+ format: str
127
+ source_sha256: str
128
+ target_artifact: ArtifactRef
129
+ overwritten: bool
130
+ provenance: Provenance
131
+ issues: tuple[Issue, ...]
132
+
133
+ def __init__(
134
+ self,
135
+ source_sha256: str,
136
+ target_artifact: ArtifactRef,
137
+ *,
138
+ format: str = "svg",
139
+ overwritten: bool,
140
+ provenance: Provenance | None = None,
141
+ issues: Iterable[Issue] = (),
142
+ ) -> None:
143
+ if (
144
+ not isinstance(source_sha256, str)
145
+ or len(source_sha256) != 64
146
+ or any(character not in "0123456789abcdefABCDEF" for character in source_sha256)
147
+ ):
148
+ raise ConfigurationError("source_sha256 must contain 64 hexadecimal digits.")
149
+ if not isinstance(target_artifact, ArtifactRef):
150
+ raise ConfigurationError("target_artifact must be ArtifactRef.")
151
+ if format not in {"svg", "png", "tiff", "pdf", "html"}:
152
+ raise ConfigurationError("Unknown visualization output format.")
153
+ if not isinstance(overwritten, bool):
154
+ raise ConfigurationError("overwritten must be a boolean.")
155
+ if provenance is not None and not isinstance(provenance, Provenance):
156
+ raise ConfigurationError("provenance must be Provenance or None.")
157
+ issue_tuple = tuple(issues)
158
+ if any(not isinstance(issue, Issue) for issue in issue_tuple):
159
+ raise ConfigurationError("issues must contain Issue objects.")
160
+ object.__setattr__(self, "format", format)
161
+ object.__setattr__(self, "source_sha256", source_sha256.lower())
162
+ object.__setattr__(self, "target_artifact", target_artifact)
163
+ object.__setattr__(self, "overwritten", overwritten)
164
+ object.__setattr__(
165
+ self, "provenance", provenance if provenance is not None else Provenance()
166
+ )
167
+ object.__setattr__(self, "issues", issue_tuple)
168
+
169
+ def to_dict(self) -> dict[str, Any]:
170
+ return cast(dict[str, Any], to_json_compatible(self))
171
+
172
+
173
+ @dataclass(frozen=True, init=False)
174
+ class HTMLReportArtifact:
175
+ """A bounded self-contained HTML report with no external resources."""
176
+
177
+ html: str
178
+ record_count: int
179
+ result_names: tuple[str, ...]
180
+ provenance: Provenance
181
+ issues: tuple[Issue, ...]
182
+
183
+ def __init__(
184
+ self,
185
+ html: str,
186
+ record_count: int,
187
+ result_names: Iterable[str],
188
+ *,
189
+ provenance: Provenance | None = None,
190
+ issues: Iterable[Issue] = (),
191
+ ) -> None:
192
+ if not isinstance(html, str) or not html.startswith("<!doctype html>"):
193
+ raise ConfigurationError("HTMLReportArtifact html must be a complete HTML document.")
194
+ if isinstance(record_count, bool) or not isinstance(record_count, int) or record_count < 0:
195
+ raise ConfigurationError("record_count must be a non-negative integer.")
196
+ names = tuple(result_names)
197
+ if any(not isinstance(name, str) or not name.strip() for name in names):
198
+ raise ConfigurationError("result_names must contain non-empty strings.")
199
+ if len(set(names)) != len(names):
200
+ raise ConfigurationError("result_names must be unique.")
201
+ resolved_provenance = Provenance() if provenance is None else provenance
202
+ if not isinstance(resolved_provenance, Provenance):
203
+ raise ConfigurationError("provenance must be Provenance or None.")
204
+ issue_tuple = tuple(issues)
205
+ if any(not isinstance(issue, Issue) for issue in issue_tuple):
206
+ raise ConfigurationError("issues must contain Issue objects.")
207
+ object.__setattr__(self, "html", html)
208
+ object.__setattr__(self, "record_count", record_count)
209
+ object.__setattr__(self, "result_names", names)
210
+ object.__setattr__(self, "provenance", resolved_provenance)
211
+ object.__setattr__(self, "issues", issue_tuple)
212
+
213
+ @property
214
+ def sha256(self) -> str:
215
+ return hashlib.sha256(self.html.encode("utf-8")).hexdigest()
216
+
217
+ def to_dict(self, *, include_html: bool = False) -> dict[str, Any]:
218
+ payload: dict[str, object] = {
219
+ "record_count": self.record_count,
220
+ "result_names": self.result_names,
221
+ "sha256": self.sha256,
222
+ "provenance": self.provenance,
223
+ "issues": self.issues,
224
+ }
225
+ if include_html:
226
+ payload["html"] = self.html
227
+ return cast(dict[str, Any], to_json_compatible(payload))
228
+
229
+
230
+ __all__ = [
231
+ "MAX_SVG_ELEMENTS",
232
+ "MAX_SVG_INPUT_BYTES",
233
+ "HTMLReportArtifact",
234
+ "SVGArtifact",
235
+ "VisualizationSaveResult",
236
+ ]