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.
Files changed (57) 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 +440 -0
  5. sdr_visualizer/adapters/base.py +1 -0
  6. sdr_visualizer/adapters/cja.py +593 -0
  7. sdr_visualizer/adapters/cja_lineage.py +547 -0
  8. sdr_visualizer/analysis/__init__.py +0 -0
  9. sdr_visualizer/analysis/diff.py +133 -0
  10. sdr_visualizer/analysis/formula_tree.py +128 -0
  11. sdr_visualizer/analysis/lineage_layout.py +351 -0
  12. sdr_visualizer/analysis/references.py +80 -0
  13. sdr_visualizer/analysis/segment_tree.py +201 -0
  14. sdr_visualizer/analysis/trend.py +71 -0
  15. sdr_visualizer/cli/__init__.py +0 -0
  16. sdr_visualizer/cli/exit_codes.py +9 -0
  17. sdr_visualizer/cli/lineage.py +113 -0
  18. sdr_visualizer/cli/lineage_output.py +137 -0
  19. sdr_visualizer/cli/main.py +492 -0
  20. sdr_visualizer/cli/output_safety.py +28 -0
  21. sdr_visualizer/core/__init__.py +0 -0
  22. sdr_visualizer/core/exceptions.py +13 -0
  23. sdr_visualizer/core/lineage.py +174 -0
  24. sdr_visualizer/core/models.py +88 -0
  25. sdr_visualizer/core/structure_limits.py +125 -0
  26. sdr_visualizer/core/visualizer.py +46 -0
  27. sdr_visualizer/input/__init__.py +0 -0
  28. sdr_visualizer/input/detect.py +37 -0
  29. sdr_visualizer/input/lineage_discovery.py +382 -0
  30. sdr_visualizer/input/loader.py +182 -0
  31. sdr_visualizer/input/series.py +142 -0
  32. sdr_visualizer/input/shell_out.py +117 -0
  33. sdr_visualizer/render/__init__.py +0 -0
  34. sdr_visualizer/render/color_packs.py +391 -0
  35. sdr_visualizer/render/data_payload.py +195 -0
  36. sdr_visualizer/render/lineage_payload.py +306 -0
  37. sdr_visualizer/render/lineage_renderer.py +95 -0
  38. sdr_visualizer/render/renderer.py +127 -0
  39. sdr_visualizer/render/static/cja_lineage.css +303 -0
  40. sdr_visualizer/render/static/cja_lineage.js +1476 -0
  41. sdr_visualizer/render/static/cja_lineage_graph.js +592 -0
  42. sdr_visualizer/render/static/d3.min.js +2 -0
  43. sdr_visualizer/render/static/visualizer.css +915 -0
  44. sdr_visualizer/render/static/visualizer.js +1461 -0
  45. sdr_visualizer/render/templates/catalog.html.j2 +73 -0
  46. sdr_visualizer/render/templates/changes.html.j2 +16 -0
  47. sdr_visualizer/render/templates/cja_lineage.html.j2 +190 -0
  48. sdr_visualizer/render/templates/graph.html.j2 +46 -0
  49. sdr_visualizer/render/templates/index.html.j2 +102 -0
  50. sdr_visualizer/render/templates/trend.html.j2 +13 -0
  51. sdr_visualizer/render/trend_charts.py +72 -0
  52. sdr_visualizer-1.1.0.dist-info/METADATA +350 -0
  53. sdr_visualizer-1.1.0.dist-info/RECORD +57 -0
  54. sdr_visualizer-1.1.0.dist-info/WHEEL +4 -0
  55. sdr_visualizer-1.1.0.dist-info/entry_points.txt +3 -0
  56. sdr_visualizer-1.1.0.dist-info/licenses/LICENSE +21 -0
  57. sdr_visualizer-1.1.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__ = "1.1.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,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."""