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
|
@@ -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 = {"&": "&", "<": "<", ">": ">"}
|
|
84
|
+
if quote:
|
|
85
|
+
replacements.update({'"': """, "'": "'"})
|
|
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
|
+
]
|