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.
Files changed (42) hide show
  1. sdr_visualizer/__init__.py +3 -0
  2. sdr_visualizer/__main__.py +6 -0
  3. sdr_visualizer/adapters/__init__.py +0 -0
  4. sdr_visualizer/adapters/aa.py +370 -0
  5. sdr_visualizer/adapters/base.py +1 -0
  6. sdr_visualizer/adapters/cja.py +496 -0
  7. sdr_visualizer/analysis/__init__.py +0 -0
  8. sdr_visualizer/analysis/diff.py +133 -0
  9. sdr_visualizer/analysis/formula_tree.py +121 -0
  10. sdr_visualizer/analysis/references.py +74 -0
  11. sdr_visualizer/analysis/segment_tree.py +198 -0
  12. sdr_visualizer/analysis/trend.py +71 -0
  13. sdr_visualizer/cli/__init__.py +0 -0
  14. sdr_visualizer/cli/exit_codes.py +9 -0
  15. sdr_visualizer/cli/main.py +322 -0
  16. sdr_visualizer/core/__init__.py +0 -0
  17. sdr_visualizer/core/exceptions.py +13 -0
  18. sdr_visualizer/core/models.py +88 -0
  19. sdr_visualizer/core/visualizer.py +45 -0
  20. sdr_visualizer/input/__init__.py +0 -0
  21. sdr_visualizer/input/detect.py +31 -0
  22. sdr_visualizer/input/loader.py +158 -0
  23. sdr_visualizer/input/series.py +116 -0
  24. sdr_visualizer/input/shell_out.py +67 -0
  25. sdr_visualizer/render/__init__.py +0 -0
  26. sdr_visualizer/render/data_payload.py +181 -0
  27. sdr_visualizer/render/renderer.py +111 -0
  28. sdr_visualizer/render/static/d3.min.js +2 -0
  29. sdr_visualizer/render/static/visualizer.css +836 -0
  30. sdr_visualizer/render/static/visualizer.js +1275 -0
  31. sdr_visualizer/render/templates/catalog.html.j2 +73 -0
  32. sdr_visualizer/render/templates/changes.html.j2 +16 -0
  33. sdr_visualizer/render/templates/graph.html.j2 +46 -0
  34. sdr_visualizer/render/templates/index.html.j2 +99 -0
  35. sdr_visualizer/render/templates/trend.html.j2 +13 -0
  36. sdr_visualizer/render/trend_charts.py +68 -0
  37. sdr_visualizer-0.6.0.dist-info/METADATA +164 -0
  38. sdr_visualizer-0.6.0.dist-info/RECORD +42 -0
  39. sdr_visualizer-0.6.0.dist-info/WHEEL +4 -0
  40. sdr_visualizer-0.6.0.dist-info/entry_points.txt +2 -0
  41. sdr_visualizer-0.6.0.dist-info/licenses/LICENSE +21 -0
  42. sdr_visualizer-0.6.0.dist-info/licenses/THIRD_PARTY_LICENSES +27 -0
@@ -0,0 +1,3 @@
1
+ """sdr-visualizer: visual catalog generator for CJA / AA implementations."""
2
+
3
+ __version__ = "0.6.0"
@@ -0,0 +1,6 @@
1
+ """Allow `python -m sdr_visualizer`."""
2
+
3
+ from sdr_visualizer.cli.main import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
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)."""