sdr-visualizer 0.6.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 +370 -0
- sdr_visualizer/adapters/base.py +1 -0
- sdr_visualizer/adapters/cja.py +496 -0
- sdr_visualizer/analysis/__init__.py +0 -0
- sdr_visualizer/analysis/diff.py +133 -0
- sdr_visualizer/analysis/formula_tree.py +121 -0
- sdr_visualizer/analysis/references.py +74 -0
- sdr_visualizer/analysis/segment_tree.py +198 -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/main.py +322 -0
- sdr_visualizer/core/__init__.py +0 -0
- sdr_visualizer/core/exceptions.py +13 -0
- sdr_visualizer/core/models.py +88 -0
- sdr_visualizer/core/visualizer.py +45 -0
- sdr_visualizer/input/__init__.py +0 -0
- sdr_visualizer/input/detect.py +31 -0
- sdr_visualizer/input/loader.py +158 -0
- sdr_visualizer/input/series.py +116 -0
- sdr_visualizer/input/shell_out.py +67 -0
- sdr_visualizer/render/__init__.py +0 -0
- sdr_visualizer/render/data_payload.py +181 -0
- sdr_visualizer/render/renderer.py +111 -0
- sdr_visualizer/render/static/d3.min.js +2 -0
- sdr_visualizer/render/static/visualizer.css +836 -0
- sdr_visualizer/render/static/visualizer.js +1275 -0
- sdr_visualizer/render/templates/catalog.html.j2 +73 -0
- sdr_visualizer/render/templates/changes.html.j2 +16 -0
- sdr_visualizer/render/templates/graph.html.j2 +46 -0
- sdr_visualizer/render/templates/index.html.j2 +99 -0
- sdr_visualizer/render/templates/trend.html.j2 +13 -0
- sdr_visualizer/render/trend_charts.py +68 -0
- sdr_visualizer-0.6.0.dist-info/METADATA +164 -0
- sdr_visualizer-0.6.0.dist-info/RECORD +42 -0
- sdr_visualizer-0.6.0.dist-info/WHEEL +4 -0
- sdr_visualizer-0.6.0.dist-info/entry_points.txt +2 -0
- sdr_visualizer-0.6.0.dist-info/licenses/LICENSE +21 -0
- sdr_visualizer-0.6.0.dist-info/licenses/THIRD_PARTY_LICENSES +27 -0
|
File without changes
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
"""AA adapter: aa_auto_sdr JSON output -> normalized Implementation.
|
|
2
|
+
|
|
3
|
+
Per SPEC §5: eVars and props both map to dimensions (with platform_specific
|
|
4
|
+
preserving 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
|
+
|
|
21
|
+
|
|
22
|
+
def adapt(snapshot: dict[str, Any], *, source: str = "<unknown>") -> Implementation:
|
|
23
|
+
"""Convert a parsed aa_auto_sdr JSON snapshot into an Implementation."""
|
|
24
|
+
if not isinstance(snapshot, dict):
|
|
25
|
+
raise InvalidSnapshotError(f"expected top-level JSON object, got {type(snapshot).__name__}")
|
|
26
|
+
|
|
27
|
+
if "report_suite" in snapshot:
|
|
28
|
+
rs = snapshot["report_suite"]
|
|
29
|
+
elif "reportSuite" in snapshot:
|
|
30
|
+
rs = snapshot["reportSuite"]
|
|
31
|
+
else:
|
|
32
|
+
raise InvalidSnapshotError("AA snapshot missing 'report_suite' object; not an AA snapshot?")
|
|
33
|
+
if not isinstance(rs, dict):
|
|
34
|
+
raise InvalidSnapshotError(
|
|
35
|
+
f"AA snapshot 'report_suite' must be an object; got {type(rs).__name__}"
|
|
36
|
+
)
|
|
37
|
+
instance_id = rs.get("rsid") or rs.get("RSID")
|
|
38
|
+
if not instance_id:
|
|
39
|
+
raise InvalidSnapshotError("AA snapshot 'report_suite' missing 'rsid'")
|
|
40
|
+
instance_name = rs.get("name") or instance_id
|
|
41
|
+
snapshot_taken_at = snapshot.get("captured_at") or snapshot.get("captured")
|
|
42
|
+
if isinstance(snapshot_taken_at, str):
|
|
43
|
+
snapshot_taken_at = snapshot_taken_at.strip() or None
|
|
44
|
+
adapter_version = str(snapshot.get("tool_version") or "unknown")
|
|
45
|
+
|
|
46
|
+
dims_raw = _ensure_list(snapshot, "dimensions")
|
|
47
|
+
metrics_raw = _ensure_list(snapshot, "metrics")
|
|
48
|
+
classifications_by_parent = _index_classifications(snapshot.get("classifications"))
|
|
49
|
+
|
|
50
|
+
dimensions = [
|
|
51
|
+
_component_from_record(r, "dimension", classifications_by_parent) for r in dims_raw
|
|
52
|
+
]
|
|
53
|
+
metrics = [_component_from_record(r, "metric", classifications_by_parent) for r in metrics_raw]
|
|
54
|
+
calculated_metrics = [
|
|
55
|
+
_calc_from_record(r) for r in _optional_list(snapshot, "calculated_metrics")
|
|
56
|
+
]
|
|
57
|
+
segments = [_segment_from_record(r) for r in _optional_list(snapshot, "segments")]
|
|
58
|
+
|
|
59
|
+
return Implementation(
|
|
60
|
+
platform="aa",
|
|
61
|
+
instance_id=str(instance_id),
|
|
62
|
+
instance_name=str(instance_name),
|
|
63
|
+
snapshot_taken_at=snapshot_taken_at,
|
|
64
|
+
snapshot_source=source,
|
|
65
|
+
adapter_version=adapter_version,
|
|
66
|
+
metrics=metrics,
|
|
67
|
+
dimensions=dimensions,
|
|
68
|
+
segments=segments,
|
|
69
|
+
calculated_metrics=calculated_metrics,
|
|
70
|
+
derived_fields=[], # CJA-only concept
|
|
71
|
+
raw=snapshot,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# Components (dimensions and metrics)
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _component_from_record(
|
|
81
|
+
record: Any, component_type: str, classifications_by_parent: dict[str, list[str]]
|
|
82
|
+
) -> Component:
|
|
83
|
+
if not isinstance(record, dict):
|
|
84
|
+
raise InvalidSnapshotError(
|
|
85
|
+
f"expected {component_type} record to be an object, got {type(record).__name__}"
|
|
86
|
+
)
|
|
87
|
+
component_id = record.get("id")
|
|
88
|
+
if not component_id:
|
|
89
|
+
raise InvalidSnapshotError(f"{component_type} record is missing 'id': {record!r}")
|
|
90
|
+
|
|
91
|
+
name = record.get("name") or component_id
|
|
92
|
+
description = _normalize_description(record.get("description"))
|
|
93
|
+
data_type = record.get("type")
|
|
94
|
+
polarity = _normalize_polarity(record.get("polarity"))
|
|
95
|
+
tags = _parse_tag_list(record.get("tags"))
|
|
96
|
+
# Pick up classifications attached to this component as tags.
|
|
97
|
+
extra_class_tags = classifications_by_parent.get(str(component_id), [])
|
|
98
|
+
if extra_class_tags:
|
|
99
|
+
tags = sorted(set([*tags, *extra_class_tags]))
|
|
100
|
+
|
|
101
|
+
handled = {"id", "name", "description", "type", "polarity", "tags"}
|
|
102
|
+
platform_specific = {k: v for k, v in record.items() if k not in handled}
|
|
103
|
+
|
|
104
|
+
return Component(
|
|
105
|
+
id=str(component_id),
|
|
106
|
+
name=str(name),
|
|
107
|
+
description=description,
|
|
108
|
+
component_type=component_type, # type: ignore[arg-type]
|
|
109
|
+
data_type=str(data_type) if data_type else None,
|
|
110
|
+
polarity=polarity,
|
|
111
|
+
created_at=record.get("created"),
|
|
112
|
+
modified_at=record.get("modified"),
|
|
113
|
+
owner=str(record.get("owner_id")) if record.get("owner_id") else None,
|
|
114
|
+
tags=tags,
|
|
115
|
+
platform_specific=platform_specific,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _index_classifications(classifications: Any) -> dict[str, list[str]]:
|
|
120
|
+
"""AA classifications attach to a parent dimension by ID; surface as tags."""
|
|
121
|
+
out: dict[str, list[str]] = {}
|
|
122
|
+
if not isinstance(classifications, list):
|
|
123
|
+
return out
|
|
124
|
+
for entry in classifications:
|
|
125
|
+
if not isinstance(entry, dict):
|
|
126
|
+
continue
|
|
127
|
+
parent = entry.get("parent")
|
|
128
|
+
if not parent:
|
|
129
|
+
continue
|
|
130
|
+
label = entry.get("name") or entry.get("id")
|
|
131
|
+
if not label:
|
|
132
|
+
continue
|
|
133
|
+
out.setdefault(str(parent), []).append(str(label))
|
|
134
|
+
return out
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
# Calculated metrics
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _stringify_formula(formula: dict[str, Any]) -> str:
|
|
143
|
+
func = formula.get("func")
|
|
144
|
+
if not func:
|
|
145
|
+
return ""
|
|
146
|
+
args = formula.get("args") or []
|
|
147
|
+
if not isinstance(args, list):
|
|
148
|
+
args = [args]
|
|
149
|
+
return f"{func}({', '.join(_stringify_formula_arg(a) for a in args)})"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _stringify_formula_arg(arg: Any) -> str:
|
|
153
|
+
if isinstance(arg, dict):
|
|
154
|
+
# Nested formula: render it the same way instead of leaking a
|
|
155
|
+
# Python dict repr into user-facing formula summaries.
|
|
156
|
+
rendered = _stringify_formula(arg)
|
|
157
|
+
return rendered or str(arg.get("func") or "?")
|
|
158
|
+
return str(arg)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _calc_from_record(record: Any) -> CalculatedMetric:
|
|
162
|
+
if not isinstance(record, dict):
|
|
163
|
+
raise InvalidSnapshotError(
|
|
164
|
+
f"expected calculated metric to be an object, got {type(record).__name__}"
|
|
165
|
+
)
|
|
166
|
+
metric_id = record.get("id")
|
|
167
|
+
if not metric_id:
|
|
168
|
+
raise InvalidSnapshotError(f"calc metric missing 'id': {record!r}")
|
|
169
|
+
name = record.get("name") or metric_id
|
|
170
|
+
description = _normalize_description(record.get("description"))
|
|
171
|
+
definition = record.get("definition") or {}
|
|
172
|
+
formula = definition.get("formula") if isinstance(definition, dict) else {}
|
|
173
|
+
formula_text = _stringify_formula(formula) if isinstance(formula, dict) else ""
|
|
174
|
+
references = _extract_aa_calc_refs(formula)
|
|
175
|
+
|
|
176
|
+
return CalculatedMetric(
|
|
177
|
+
id=str(metric_id),
|
|
178
|
+
name=str(name),
|
|
179
|
+
description=description,
|
|
180
|
+
formula=formula if isinstance(formula, dict) else {},
|
|
181
|
+
formula_text=formula_text,
|
|
182
|
+
attribution_model=record.get("attribution") or record.get("attribution_model"),
|
|
183
|
+
allocation=record.get("allocation"),
|
|
184
|
+
complexity_score=_as_float(record.get("complexity_score")),
|
|
185
|
+
references=references,
|
|
186
|
+
created_at=record.get("created") or record.get("created_at"),
|
|
187
|
+
modified_at=record.get("modified") or record.get("modified_at"),
|
|
188
|
+
owner=str(record.get("owner_id")) if record.get("owner_id") else None,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _extract_aa_calc_refs(formula: Any) -> list[str]:
|
|
193
|
+
"""Walk an AA calc-metric formula and collect any metrics/* args."""
|
|
194
|
+
refs: list[str] = []
|
|
195
|
+
seen: set[str] = set()
|
|
196
|
+
|
|
197
|
+
def walk(node: Any) -> None:
|
|
198
|
+
if isinstance(node, dict):
|
|
199
|
+
args = node.get("args")
|
|
200
|
+
if isinstance(args, list):
|
|
201
|
+
for arg in args:
|
|
202
|
+
if isinstance(arg, str) and arg.startswith(("metrics/", "variables/")):
|
|
203
|
+
if arg not in seen:
|
|
204
|
+
seen.add(arg)
|
|
205
|
+
refs.append(arg)
|
|
206
|
+
else:
|
|
207
|
+
walk(arg)
|
|
208
|
+
for value in node.values():
|
|
209
|
+
if value is args:
|
|
210
|
+
continue
|
|
211
|
+
walk(value)
|
|
212
|
+
elif isinstance(node, list):
|
|
213
|
+
for item in node:
|
|
214
|
+
walk(item)
|
|
215
|
+
|
|
216
|
+
walk(formula)
|
|
217
|
+
return refs
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# ---------------------------------------------------------------------------
|
|
221
|
+
# Segments
|
|
222
|
+
# ---------------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _segment_from_record(record: Any) -> Segment:
|
|
226
|
+
if not isinstance(record, dict):
|
|
227
|
+
raise InvalidSnapshotError(f"expected segment to be an object, got {type(record).__name__}")
|
|
228
|
+
segment_id = record.get("id")
|
|
229
|
+
if not segment_id:
|
|
230
|
+
raise InvalidSnapshotError(f"segment missing 'id': {record!r}")
|
|
231
|
+
name = record.get("name") or segment_id
|
|
232
|
+
description = _normalize_description(record.get("description"))
|
|
233
|
+
definition = record.get("definition") or {}
|
|
234
|
+
nesting_depth, container_types = _walk_segment_definition(definition)
|
|
235
|
+
references: list[str] = [] # AA segments don't expose direct cross-refs in the basic shape
|
|
236
|
+
|
|
237
|
+
return Segment(
|
|
238
|
+
id=str(segment_id),
|
|
239
|
+
name=str(name),
|
|
240
|
+
description=description,
|
|
241
|
+
definition=definition if isinstance(definition, dict) else {},
|
|
242
|
+
nesting_depth=nesting_depth,
|
|
243
|
+
container_types=container_types,
|
|
244
|
+
references=references,
|
|
245
|
+
created_at=record.get("created"),
|
|
246
|
+
modified_at=record.get("modified"),
|
|
247
|
+
owner=str(record.get("owner_id")) if record.get("owner_id") else None,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _walk_segment_definition(definition: Any) -> tuple[int, list[str]]:
|
|
252
|
+
"""Compute container nesting depth and distinct container contexts.
|
|
253
|
+
|
|
254
|
+
Depth counts only `func == "container"` nodes along the deepest
|
|
255
|
+
container chain — not raw JSON nesting. A definition with no
|
|
256
|
+
containers has depth 0.
|
|
257
|
+
"""
|
|
258
|
+
contexts: list[str] = []
|
|
259
|
+
seen: set[str] = set()
|
|
260
|
+
|
|
261
|
+
def visit(node: Any, depth: int) -> int:
|
|
262
|
+
max_depth = depth
|
|
263
|
+
if isinstance(node, dict):
|
|
264
|
+
child_depth = depth
|
|
265
|
+
if node.get("func") == "container":
|
|
266
|
+
child_depth = depth + 1
|
|
267
|
+
max_depth = child_depth
|
|
268
|
+
if node.get("context"):
|
|
269
|
+
ctx = str(node["context"])
|
|
270
|
+
if ctx not in seen:
|
|
271
|
+
seen.add(ctx)
|
|
272
|
+
contexts.append(ctx)
|
|
273
|
+
for value in node.values():
|
|
274
|
+
max_depth = max(max_depth, visit(value, child_depth))
|
|
275
|
+
elif isinstance(node, list):
|
|
276
|
+
for item in node:
|
|
277
|
+
max_depth = max(max_depth, visit(item, depth))
|
|
278
|
+
return max_depth
|
|
279
|
+
|
|
280
|
+
return visit(definition, 0), contexts
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
# ---------------------------------------------------------------------------
|
|
284
|
+
# Helpers
|
|
285
|
+
# ---------------------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _parse_tag_list(value: Any) -> list[str]:
|
|
289
|
+
"""aa_auto_sdr can ship `tags` as a JSON-encoded list string, same as
|
|
290
|
+
cja_auto_sdr (see cja.py's copy — adapters stay standalone reference
|
|
291
|
+
examples, so this helper is intentionally duplicated). Handles native
|
|
292
|
+
lists, stringified lists, and falls back to [] for anything else. Kept
|
|
293
|
+
behavior-identical to sdr-grader's copy (SPEC §11/§15)."""
|
|
294
|
+
if value is None or value == "":
|
|
295
|
+
return []
|
|
296
|
+
if isinstance(value, list):
|
|
297
|
+
return [str(t) for t in value]
|
|
298
|
+
if isinstance(value, str):
|
|
299
|
+
try:
|
|
300
|
+
parsed = json.loads(value)
|
|
301
|
+
except json.JSONDecodeError:
|
|
302
|
+
return []
|
|
303
|
+
if isinstance(parsed, list):
|
|
304
|
+
return [str(t) for t in parsed]
|
|
305
|
+
return []
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _optional_list(snapshot: dict[str, Any], key: str) -> list[Any]:
|
|
309
|
+
"""Optional sections (segments, calculated_metrics) may be absent or null,
|
|
310
|
+
but a present non-list value is a malformed export, not an empty one.
|
|
311
|
+
Vendored verbatim from sdr-grader (SPEC §11/§15)."""
|
|
312
|
+
value = snapshot.get(key)
|
|
313
|
+
if value is None:
|
|
314
|
+
return []
|
|
315
|
+
if not isinstance(value, list):
|
|
316
|
+
raise InvalidSnapshotError(
|
|
317
|
+
f"AA snapshot '{key}' must be a list, got {type(value).__name__}"
|
|
318
|
+
)
|
|
319
|
+
return value
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _as_float(value: Any) -> float:
|
|
323
|
+
"""The visualizer's variant of sdr-grader's `_safe_float` (SPEC §11/§15).
|
|
324
|
+
Two intentional deltas from the grader, both driven by visualizer-only
|
|
325
|
+
behavior — do NOT reconcile them away to match the sibling:
|
|
326
|
+
|
|
327
|
+
1. A present but unconvertible value RAISES InvalidSnapshotError (the
|
|
328
|
+
grader returns a default). Trend mode relies on the raise to skip a
|
|
329
|
+
malformed snapshot; a single snapshot exits 3.
|
|
330
|
+
2. NaN/Infinity pass through unchanged (the grader coerces them to a
|
|
331
|
+
default). The renderer's allow_nan=False guard then rejects the
|
|
332
|
+
snapshot loudly (audit H2) — a report that cannot boot in a browser is
|
|
333
|
+
worse than a rejected one.
|
|
334
|
+
|
|
335
|
+
Falsy input keeps the old `value or 0.0` default."""
|
|
336
|
+
if not value:
|
|
337
|
+
return 0.0
|
|
338
|
+
try:
|
|
339
|
+
return float(value)
|
|
340
|
+
except (TypeError, ValueError) as exc:
|
|
341
|
+
raise InvalidSnapshotError(f"expected a number, got {value!r}") from exc
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _ensure_list(snapshot: dict[str, Any], key: str) -> list[Any]:
|
|
345
|
+
value = snapshot.get(key) or []
|
|
346
|
+
if not isinstance(value, list):
|
|
347
|
+
raise InvalidSnapshotError(
|
|
348
|
+
f"AA snapshot '{key}' must be a list, got {type(value).__name__}"
|
|
349
|
+
)
|
|
350
|
+
return value
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _normalize_description(value: Any) -> str | None:
|
|
354
|
+
if value is None:
|
|
355
|
+
return None
|
|
356
|
+
if not isinstance(value, str):
|
|
357
|
+
return None
|
|
358
|
+
stripped = value.strip()
|
|
359
|
+
if not stripped or stripped == "-":
|
|
360
|
+
return None
|
|
361
|
+
return stripped
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _normalize_polarity(value: Any):
|
|
365
|
+
if not isinstance(value, str):
|
|
366
|
+
return None
|
|
367
|
+
lowered = value.strip().lower()
|
|
368
|
+
if lowered in {"positive", "negative", "neutral"}:
|
|
369
|
+
return lowered # type: ignore[return-value]
|
|
370
|
+
return None
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Adapter protocol (SPEC-VISUALIZER §5)."""
|