sdr-visualizer 1.1.0__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.
- sdr_visualizer/__init__.py +3 -0
- sdr_visualizer/__main__.py +6 -0
- sdr_visualizer/adapters/__init__.py +0 -0
- sdr_visualizer/adapters/aa.py +440 -0
- sdr_visualizer/adapters/base.py +1 -0
- sdr_visualizer/adapters/cja.py +593 -0
- sdr_visualizer/adapters/cja_lineage.py +547 -0
- sdr_visualizer/analysis/__init__.py +0 -0
- sdr_visualizer/analysis/diff.py +133 -0
- sdr_visualizer/analysis/formula_tree.py +128 -0
- sdr_visualizer/analysis/lineage_layout.py +351 -0
- sdr_visualizer/analysis/references.py +80 -0
- sdr_visualizer/analysis/segment_tree.py +201 -0
- sdr_visualizer/analysis/trend.py +71 -0
- sdr_visualizer/cli/__init__.py +0 -0
- sdr_visualizer/cli/exit_codes.py +9 -0
- sdr_visualizer/cli/lineage.py +113 -0
- sdr_visualizer/cli/lineage_output.py +137 -0
- sdr_visualizer/cli/main.py +492 -0
- sdr_visualizer/cli/output_safety.py +28 -0
- sdr_visualizer/core/__init__.py +0 -0
- sdr_visualizer/core/exceptions.py +13 -0
- sdr_visualizer/core/lineage.py +174 -0
- sdr_visualizer/core/models.py +88 -0
- sdr_visualizer/core/structure_limits.py +125 -0
- sdr_visualizer/core/visualizer.py +46 -0
- sdr_visualizer/input/__init__.py +0 -0
- sdr_visualizer/input/detect.py +37 -0
- sdr_visualizer/input/lineage_discovery.py +382 -0
- sdr_visualizer/input/loader.py +182 -0
- sdr_visualizer/input/series.py +142 -0
- sdr_visualizer/input/shell_out.py +117 -0
- sdr_visualizer/render/__init__.py +0 -0
- sdr_visualizer/render/color_packs.py +391 -0
- sdr_visualizer/render/data_payload.py +195 -0
- sdr_visualizer/render/lineage_payload.py +306 -0
- sdr_visualizer/render/lineage_renderer.py +95 -0
- sdr_visualizer/render/renderer.py +127 -0
- sdr_visualizer/render/static/cja_lineage.css +303 -0
- sdr_visualizer/render/static/cja_lineage.js +1476 -0
- sdr_visualizer/render/static/cja_lineage_graph.js +592 -0
- sdr_visualizer/render/static/d3.min.js +2 -0
- sdr_visualizer/render/static/visualizer.css +915 -0
- sdr_visualizer/render/static/visualizer.js +1461 -0
- sdr_visualizer/render/templates/catalog.html.j2 +73 -0
- sdr_visualizer/render/templates/changes.html.j2 +16 -0
- sdr_visualizer/render/templates/cja_lineage.html.j2 +190 -0
- sdr_visualizer/render/templates/graph.html.j2 +46 -0
- sdr_visualizer/render/templates/index.html.j2 +102 -0
- sdr_visualizer/render/templates/trend.html.j2 +13 -0
- sdr_visualizer/render/trend_charts.py +72 -0
- sdr_visualizer-1.1.0.dist-info/METADATA +350 -0
- sdr_visualizer-1.1.0.dist-info/RECORD +57 -0
- sdr_visualizer-1.1.0.dist-info/WHEEL +4 -0
- sdr_visualizer-1.1.0.dist-info/entry_points.txt +3 -0
- sdr_visualizer-1.1.0.dist-info/licenses/LICENSE +21 -0
- sdr_visualizer-1.1.0.dist-info/licenses/THIRD_PARTY_LICENSES +27 -0
|
File without changes
|
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
"""AA adapter: aa_auto_sdr JSON output -> normalized Implementation.
|
|
2
|
+
|
|
3
|
+
eVars and props both map to dimensions (with platform_specific preserving
|
|
4
|
+
allocation/expiration/prop-specific flags); events map to metrics;
|
|
5
|
+
classifications attach as tags on the parent dimension.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from sdr_visualizer.core.exceptions import InvalidSnapshotError
|
|
14
|
+
from sdr_visualizer.core.models import (
|
|
15
|
+
CalculatedMetric,
|
|
16
|
+
Component,
|
|
17
|
+
Implementation,
|
|
18
|
+
Segment,
|
|
19
|
+
)
|
|
20
|
+
from sdr_visualizer.core.structure_limits import (
|
|
21
|
+
validate_decoded_structure,
|
|
22
|
+
validate_definition_structure,
|
|
23
|
+
validate_snapshot_structure,
|
|
24
|
+
validate_unicode_scalars,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def adapt(snapshot: dict[str, Any], *, source: str = "<unknown>") -> Implementation:
|
|
29
|
+
"""Convert a parsed aa_auto_sdr JSON snapshot into an Implementation."""
|
|
30
|
+
if not isinstance(snapshot, dict):
|
|
31
|
+
raise InvalidSnapshotError(f"expected top-level JSON object, got {type(snapshot).__name__}")
|
|
32
|
+
validate_snapshot_structure(snapshot, label="AA snapshot")
|
|
33
|
+
|
|
34
|
+
if "report_suite" in snapshot:
|
|
35
|
+
rs = snapshot["report_suite"]
|
|
36
|
+
elif "reportSuite" in snapshot:
|
|
37
|
+
rs = snapshot["reportSuite"]
|
|
38
|
+
else:
|
|
39
|
+
raise InvalidSnapshotError("AA snapshot missing 'report_suite' object; not an AA snapshot?")
|
|
40
|
+
if not isinstance(rs, dict):
|
|
41
|
+
raise InvalidSnapshotError(
|
|
42
|
+
f"AA snapshot 'report_suite' must be an object; got {type(rs).__name__}"
|
|
43
|
+
)
|
|
44
|
+
instance_id = rs.get("rsid") or rs.get("RSID")
|
|
45
|
+
if not instance_id:
|
|
46
|
+
raise InvalidSnapshotError("AA snapshot 'report_suite' missing 'rsid'")
|
|
47
|
+
instance_name = rs.get("name") or instance_id
|
|
48
|
+
snapshot_taken_at = snapshot.get("captured_at") or snapshot.get("captured")
|
|
49
|
+
if isinstance(snapshot_taken_at, str):
|
|
50
|
+
snapshot_taken_at = snapshot_taken_at.strip() or None
|
|
51
|
+
else:
|
|
52
|
+
snapshot_taken_at = None
|
|
53
|
+
adapter_version = str(snapshot.get("tool_version") or "unknown")
|
|
54
|
+
|
|
55
|
+
dims_raw = _ensure_list(snapshot, "dimensions")
|
|
56
|
+
metrics_raw = _ensure_list(snapshot, "metrics")
|
|
57
|
+
classifications_by_parent = _index_classifications(snapshot.get("classifications"))
|
|
58
|
+
|
|
59
|
+
dimensions = [
|
|
60
|
+
_component_from_record(r, "dimension", classifications_by_parent) for r in dims_raw
|
|
61
|
+
]
|
|
62
|
+
metrics = [_component_from_record(r, "metric", classifications_by_parent) for r in metrics_raw]
|
|
63
|
+
calculated_metrics = [
|
|
64
|
+
_calc_from_record(r) for r in _optional_list(snapshot, "calculated_metrics")
|
|
65
|
+
]
|
|
66
|
+
segments = [_segment_from_record(r) for r in _optional_list(snapshot, "segments")]
|
|
67
|
+
|
|
68
|
+
return Implementation(
|
|
69
|
+
platform="aa",
|
|
70
|
+
instance_id=str(instance_id),
|
|
71
|
+
instance_name=str(instance_name),
|
|
72
|
+
snapshot_taken_at=snapshot_taken_at,
|
|
73
|
+
snapshot_source=source,
|
|
74
|
+
adapter_version=adapter_version,
|
|
75
|
+
metrics=metrics,
|
|
76
|
+
dimensions=dimensions,
|
|
77
|
+
segments=segments,
|
|
78
|
+
calculated_metrics=calculated_metrics,
|
|
79
|
+
derived_fields=[], # CJA-only concept
|
|
80
|
+
raw=snapshot,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
# Components (dimensions and metrics)
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _component_from_record(
|
|
90
|
+
record: Any, component_type: str, classifications_by_parent: dict[str, list[str]]
|
|
91
|
+
) -> Component:
|
|
92
|
+
if not isinstance(record, dict):
|
|
93
|
+
raise InvalidSnapshotError(
|
|
94
|
+
f"expected {component_type} record to be an object, got {type(record).__name__}"
|
|
95
|
+
)
|
|
96
|
+
component_id = record.get("id")
|
|
97
|
+
if not component_id:
|
|
98
|
+
raise InvalidSnapshotError(f"{component_type} record is missing 'id': {record!r}")
|
|
99
|
+
|
|
100
|
+
name = record.get("name") or component_id
|
|
101
|
+
description = _normalize_description(record.get("description"))
|
|
102
|
+
data_type = record.get("type")
|
|
103
|
+
polarity = _normalize_polarity(record.get("polarity"))
|
|
104
|
+
tags = _parse_tag_list(record.get("tags"))
|
|
105
|
+
# Pick up classifications attached to this component as tags.
|
|
106
|
+
extra_class_tags = classifications_by_parent.get(str(component_id), [])
|
|
107
|
+
if extra_class_tags:
|
|
108
|
+
tags = sorted(set([*tags, *extra_class_tags]))
|
|
109
|
+
|
|
110
|
+
handled = {"id", "name", "description", "type", "polarity", "tags"}
|
|
111
|
+
platform_specific = {k: v for k, v in record.items() if k not in handled}
|
|
112
|
+
|
|
113
|
+
return Component(
|
|
114
|
+
id=str(component_id),
|
|
115
|
+
name=str(name),
|
|
116
|
+
description=description,
|
|
117
|
+
component_type=component_type, # type: ignore[arg-type]
|
|
118
|
+
data_type=str(data_type) if data_type else None,
|
|
119
|
+
polarity=polarity,
|
|
120
|
+
created_at=_optional_timestamp(record.get("created")),
|
|
121
|
+
modified_at=_optional_timestamp(record.get("modified")),
|
|
122
|
+
owner=str(record.get("owner_id")) if record.get("owner_id") else None,
|
|
123
|
+
tags=tags,
|
|
124
|
+
platform_specific=platform_specific,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _index_classifications(classifications: Any) -> dict[str, list[str]]:
|
|
129
|
+
"""AA classifications attach to a parent dimension by ID; surface as tags."""
|
|
130
|
+
out: dict[str, list[str]] = {}
|
|
131
|
+
if not isinstance(classifications, list):
|
|
132
|
+
return out
|
|
133
|
+
for entry in classifications:
|
|
134
|
+
if not isinstance(entry, dict):
|
|
135
|
+
continue
|
|
136
|
+
parent = entry.get("parent")
|
|
137
|
+
if not parent:
|
|
138
|
+
continue
|
|
139
|
+
label = entry.get("name") or entry.get("id")
|
|
140
|
+
if not label:
|
|
141
|
+
continue
|
|
142
|
+
out.setdefault(str(parent), []).append(str(label))
|
|
143
|
+
return out
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
# Calculated metrics
|
|
148
|
+
# ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _stringify_formula(formula: dict[str, Any]) -> str:
|
|
152
|
+
func = formula.get("func")
|
|
153
|
+
if not func:
|
|
154
|
+
return ""
|
|
155
|
+
args = formula.get("args") or []
|
|
156
|
+
if not isinstance(args, list):
|
|
157
|
+
args = [args]
|
|
158
|
+
return f"{func}({', '.join(_stringify_formula_arg(a) for a in args)})"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _stringify_formula_arg(arg: Any) -> str:
|
|
162
|
+
if isinstance(arg, dict):
|
|
163
|
+
# Nested formula: render it the same way instead of leaking a
|
|
164
|
+
# Python dict repr into user-facing formula summaries.
|
|
165
|
+
rendered = _stringify_formula(arg)
|
|
166
|
+
return rendered or str(arg.get("func") or "?")
|
|
167
|
+
return str(arg)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _calc_from_record(record: Any) -> CalculatedMetric:
|
|
171
|
+
if not isinstance(record, dict):
|
|
172
|
+
raise InvalidSnapshotError(
|
|
173
|
+
f"expected calculated metric to be an object, got {type(record).__name__}"
|
|
174
|
+
)
|
|
175
|
+
metric_id = record.get("id")
|
|
176
|
+
if not metric_id:
|
|
177
|
+
raise InvalidSnapshotError(f"calc metric missing 'id': {record!r}")
|
|
178
|
+
name = record.get("name") or metric_id
|
|
179
|
+
description = _normalize_description(record.get("description"))
|
|
180
|
+
definition = record.get("definition") or {}
|
|
181
|
+
formula = definition.get("formula") if isinstance(definition, dict) else {}
|
|
182
|
+
if isinstance(formula, dict):
|
|
183
|
+
validate_definition_structure(formula, label=f"calculated metric formula {metric_id!r}")
|
|
184
|
+
formula_text = _stringify_formula(formula)
|
|
185
|
+
else:
|
|
186
|
+
formula_text = ""
|
|
187
|
+
references = _extract_aa_calc_refs(formula)
|
|
188
|
+
|
|
189
|
+
return CalculatedMetric(
|
|
190
|
+
id=str(metric_id),
|
|
191
|
+
name=str(name),
|
|
192
|
+
description=description,
|
|
193
|
+
formula=formula if isinstance(formula, dict) else {},
|
|
194
|
+
formula_text=formula_text,
|
|
195
|
+
attribution_model=record.get("attribution") or record.get("attribution_model"),
|
|
196
|
+
allocation=record.get("allocation"),
|
|
197
|
+
complexity_score=_as_float(record.get("complexity_score")),
|
|
198
|
+
references=references,
|
|
199
|
+
created_at=_optional_timestamp(record.get("created") or record.get("created_at")),
|
|
200
|
+
modified_at=_optional_timestamp(record.get("modified") or record.get("modified_at")),
|
|
201
|
+
owner=str(record.get("owner_id")) if record.get("owner_id") else None,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _extract_aa_calc_refs(formula: Any) -> list[str]:
|
|
206
|
+
"""Walk an AA calc-metric formula and collect any metrics/* args."""
|
|
207
|
+
refs: list[str] = []
|
|
208
|
+
seen: set[str] = set()
|
|
209
|
+
|
|
210
|
+
def walk(node: Any) -> None:
|
|
211
|
+
if isinstance(node, dict):
|
|
212
|
+
args = node.get("args")
|
|
213
|
+
if isinstance(args, list):
|
|
214
|
+
for arg in args:
|
|
215
|
+
if isinstance(arg, str) and arg.startswith(("metrics/", "variables/")):
|
|
216
|
+
if arg not in seen:
|
|
217
|
+
seen.add(arg)
|
|
218
|
+
refs.append(arg)
|
|
219
|
+
else:
|
|
220
|
+
walk(arg)
|
|
221
|
+
for value in node.values():
|
|
222
|
+
if value is args:
|
|
223
|
+
continue
|
|
224
|
+
walk(value)
|
|
225
|
+
elif isinstance(node, list):
|
|
226
|
+
for item in node:
|
|
227
|
+
walk(item)
|
|
228
|
+
|
|
229
|
+
walk(formula)
|
|
230
|
+
return refs
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# ---------------------------------------------------------------------------
|
|
234
|
+
# Segments
|
|
235
|
+
# ---------------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _segment_from_record(record: Any) -> Segment:
|
|
239
|
+
if not isinstance(record, dict):
|
|
240
|
+
raise InvalidSnapshotError(f"expected segment to be an object, got {type(record).__name__}")
|
|
241
|
+
segment_id = record.get("id")
|
|
242
|
+
if not segment_id:
|
|
243
|
+
raise InvalidSnapshotError(f"segment missing 'id': {record!r}")
|
|
244
|
+
name = record.get("name") or segment_id
|
|
245
|
+
description = _normalize_description(record.get("description"))
|
|
246
|
+
definition = record.get("definition") or {}
|
|
247
|
+
if isinstance(definition, dict):
|
|
248
|
+
validate_definition_structure(definition, label=f"segment definition {segment_id!r}")
|
|
249
|
+
nesting_depth, container_types = _walk_segment_definition(definition)
|
|
250
|
+
references: list[str] = [] # AA segments don't expose direct cross-refs in the basic shape
|
|
251
|
+
|
|
252
|
+
return Segment(
|
|
253
|
+
id=str(segment_id),
|
|
254
|
+
name=str(name),
|
|
255
|
+
description=description,
|
|
256
|
+
definition=definition if isinstance(definition, dict) else {},
|
|
257
|
+
nesting_depth=nesting_depth,
|
|
258
|
+
container_types=container_types,
|
|
259
|
+
references=references,
|
|
260
|
+
created_at=_optional_timestamp(record.get("created")),
|
|
261
|
+
modified_at=_optional_timestamp(record.get("modified")),
|
|
262
|
+
owner=str(record.get("owner_id")) if record.get("owner_id") else None,
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _walk_segment_definition(definition: Any) -> tuple[int, list[str]]:
|
|
267
|
+
"""Compute container nesting depth and distinct container contexts.
|
|
268
|
+
|
|
269
|
+
Depth counts only `func == "container"` nodes along the deepest
|
|
270
|
+
container chain — not raw JSON nesting. A definition with no
|
|
271
|
+
containers has depth 0.
|
|
272
|
+
"""
|
|
273
|
+
contexts: list[str] = []
|
|
274
|
+
seen: set[str] = set()
|
|
275
|
+
|
|
276
|
+
def visit(node: Any, depth: int) -> int:
|
|
277
|
+
max_depth = depth
|
|
278
|
+
if isinstance(node, dict):
|
|
279
|
+
child_depth = depth
|
|
280
|
+
if node.get("func") == "container":
|
|
281
|
+
child_depth = depth + 1
|
|
282
|
+
max_depth = child_depth
|
|
283
|
+
if node.get("context"):
|
|
284
|
+
ctx = str(node["context"])
|
|
285
|
+
if ctx not in seen:
|
|
286
|
+
seen.add(ctx)
|
|
287
|
+
contexts.append(ctx)
|
|
288
|
+
for value in node.values():
|
|
289
|
+
max_depth = max(max_depth, visit(value, child_depth))
|
|
290
|
+
elif isinstance(node, list):
|
|
291
|
+
for item in node:
|
|
292
|
+
max_depth = max(max_depth, visit(item, depth))
|
|
293
|
+
return max_depth
|
|
294
|
+
|
|
295
|
+
return visit(definition, 0), contexts
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
# ---------------------------------------------------------------------------
|
|
299
|
+
# Helpers
|
|
300
|
+
# ---------------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# The newest aa_auto_sdr version this release was validated against
|
|
304
|
+
# (bundled fixtures + the private real corpus; re-derive at each release:
|
|
305
|
+
# grep -rho '"Tool Version": "[^"]*"|"tool_version": "[^"]*"' over corpus
|
|
306
|
+
# and fixtures, take the max). The compatibility policy warns — never refuses.
|
|
307
|
+
TESTED_THROUGH_GENERATOR_VERSION = "1.21.10"
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def generator_version_warning(adapter_version: str) -> str | None:
|
|
311
|
+
"""Return warning text when the snapshot's generator is newer than the
|
|
312
|
+
newest version this release was tested against, else None. Unparseable
|
|
313
|
+
versions ("unknown", empty, suffixed builds) never warn. This helper has a
|
|
314
|
+
behavior-identical sibling copy in sdr-grader; the per-platform constant
|
|
315
|
+
value is the one deliberate difference."""
|
|
316
|
+
tested = _version_tuple(TESTED_THROUGH_GENERATOR_VERSION)
|
|
317
|
+
seen = _version_tuple(adapter_version)
|
|
318
|
+
if tested is None or seen is None or seen <= tested:
|
|
319
|
+
return None
|
|
320
|
+
return (
|
|
321
|
+
f"snapshot generator version {adapter_version} is newer than the "
|
|
322
|
+
f"newest version this release was tested against "
|
|
323
|
+
f"({TESTED_THROUGH_GENERATOR_VERSION}); newer snapshot fields may "
|
|
324
|
+
"not be represented"
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _version_tuple(value: str) -> tuple[int, ...] | None:
|
|
329
|
+
parts = str(value).strip().split(".")
|
|
330
|
+
try:
|
|
331
|
+
return tuple(int(p) for p in parts)
|
|
332
|
+
except ValueError:
|
|
333
|
+
return None
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _optional_timestamp(value: Any) -> str | None:
|
|
337
|
+
"""Keep created_at/modified_at only if they're already a non-empty string.
|
|
338
|
+
|
|
339
|
+
aa_auto_sdr's created/modified fields are declared str|None in the
|
|
340
|
+
internal model, but a raw export can carry a non-string value (e.g. an
|
|
341
|
+
epoch int); a bare passthrough would leak that shape straight into the
|
|
342
|
+
payload. A non-string timestamp is treated as *missing* rather than
|
|
343
|
+
coerced to a numeric string: `_compact` then drops the key, and the
|
|
344
|
+
client renders the em-dash it already shows for a genuinely absent
|
|
345
|
+
timestamp — which beats both a stringified epoch and the 1970-date bug a
|
|
346
|
+
raw int would cause downstream. Same helper as cja.py's copy (adapters
|
|
347
|
+
stay standalone reference examples, so it's intentionally duplicated;
|
|
348
|
+
see cja.py's `_optional_str` for the parallel owner/data_type guard,
|
|
349
|
+
which AA doesn't need — its owner_id path is already cast)."""
|
|
350
|
+
return value if isinstance(value, str) and value else None
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _parse_tag_list(value: Any) -> list[str]:
|
|
354
|
+
"""aa_auto_sdr can ship `tags` as a JSON-encoded list string, same as
|
|
355
|
+
cja_auto_sdr (see cja.py's copy — adapters stay standalone reference
|
|
356
|
+
examples, so this helper is intentionally duplicated). Handles native
|
|
357
|
+
lists and stringified lists. Ordinary JSON syntax failures fall back to
|
|
358
|
+
`[]`; decoder resource failures are invalid snapshot input. Kept
|
|
359
|
+
behavior-identical to sdr-grader's copy."""
|
|
360
|
+
if value is None or value == "":
|
|
361
|
+
return []
|
|
362
|
+
if isinstance(value, list):
|
|
363
|
+
return [str(t) for t in value]
|
|
364
|
+
if isinstance(value, str):
|
|
365
|
+
try:
|
|
366
|
+
parsed = json.loads(value)
|
|
367
|
+
except json.JSONDecodeError:
|
|
368
|
+
return []
|
|
369
|
+
except (ValueError, RecursionError) as exc:
|
|
370
|
+
raise InvalidSnapshotError("tag list JSON exceeds decoder limits") from exc
|
|
371
|
+
validate_decoded_structure(parsed, label="tag list")
|
|
372
|
+
validate_unicode_scalars(parsed, label="tag list")
|
|
373
|
+
if isinstance(parsed, list):
|
|
374
|
+
return [str(t) for t in parsed]
|
|
375
|
+
return []
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _optional_list(snapshot: dict[str, Any], key: str) -> list[Any]:
|
|
379
|
+
"""Optional sections (segments, calculated_metrics) may be absent or null,
|
|
380
|
+
but a present non-list value is a malformed export, not an empty one.
|
|
381
|
+
Kept behavior-identical to the sibling copy in sdr-grader."""
|
|
382
|
+
value = snapshot.get(key)
|
|
383
|
+
if value is None:
|
|
384
|
+
return []
|
|
385
|
+
if not isinstance(value, list):
|
|
386
|
+
raise InvalidSnapshotError(
|
|
387
|
+
f"AA snapshot '{key}' must be a list, got {type(value).__name__}"
|
|
388
|
+
)
|
|
389
|
+
return value
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _as_float(value: Any) -> float:
|
|
393
|
+
"""The visualizer's variant of sdr-grader's `_safe_float`.
|
|
394
|
+
Two intentional deltas from the grader, both driven by visualizer-only
|
|
395
|
+
behavior — do NOT reconcile them away to match the sibling:
|
|
396
|
+
|
|
397
|
+
1. A present but unconvertible value RAISES InvalidSnapshotError (the
|
|
398
|
+
grader returns a default). Trend mode relies on the raise to skip a
|
|
399
|
+
malformed snapshot; a single snapshot exits 3.
|
|
400
|
+
2. NaN/Infinity pass through unchanged (the grader coerces them to a
|
|
401
|
+
default). The renderer's allow_nan=False guard then rejects the
|
|
402
|
+
snapshot loudly (audit H2) — a report that cannot boot in a browser is
|
|
403
|
+
worse than a rejected one.
|
|
404
|
+
|
|
405
|
+
Falsy input keeps the old `value or 0.0` default."""
|
|
406
|
+
if not value:
|
|
407
|
+
return 0.0
|
|
408
|
+
try:
|
|
409
|
+
return float(value)
|
|
410
|
+
except (TypeError, ValueError) as exc:
|
|
411
|
+
raise InvalidSnapshotError(f"expected a number, got {value!r}") from exc
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _ensure_list(snapshot: dict[str, Any], key: str) -> list[Any]:
|
|
415
|
+
value = snapshot.get(key) or []
|
|
416
|
+
if not isinstance(value, list):
|
|
417
|
+
raise InvalidSnapshotError(
|
|
418
|
+
f"AA snapshot '{key}' must be a list, got {type(value).__name__}"
|
|
419
|
+
)
|
|
420
|
+
return value
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _normalize_description(value: Any) -> str | None:
|
|
424
|
+
if value is None:
|
|
425
|
+
return None
|
|
426
|
+
if not isinstance(value, str):
|
|
427
|
+
return None
|
|
428
|
+
stripped = value.strip()
|
|
429
|
+
if not stripped or stripped == "-":
|
|
430
|
+
return None
|
|
431
|
+
return stripped
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _normalize_polarity(value: Any):
|
|
435
|
+
if not isinstance(value, str):
|
|
436
|
+
return None
|
|
437
|
+
lowered = value.strip().lower()
|
|
438
|
+
if lowered in {"positive", "negative", "neutral"}:
|
|
439
|
+
return lowered # type: ignore[return-value]
|
|
440
|
+
return None
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Adapter protocol for the normalized implementation boundary."""
|