rtl-buddy-view 0.2.1__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 (41) hide show
  1. rtl_buddy_view/__init__.py +5 -0
  2. rtl_buddy_view/__main__.py +4 -0
  3. rtl_buddy_view/_cst_cache.py +43 -0
  4. rtl_buddy_view/_filelist.py +52 -0
  5. rtl_buddy_view/_offsets.py +23 -0
  6. rtl_buddy_view/_vcd_reader.py +356 -0
  7. rtl_buddy_view/_verible_install.py +264 -0
  8. rtl_buddy_view/_viewer_bundle/assets/index-CCzn_aMT.css +1 -0
  9. rtl_buddy_view/_viewer_bundle/assets/index-DBl-YxCO.js +7267 -0
  10. rtl_buddy_view/_viewer_bundle/index.html +20 -0
  11. rtl_buddy_view/annotations.py +431 -0
  12. rtl_buddy_view/axi_perf_annotations.py +419 -0
  13. rtl_buddy_view/cli.py +458 -0
  14. rtl_buddy_view/cst_cache.py +225 -0
  15. rtl_buddy_view/extractor.py +289 -0
  16. rtl_buddy_view/frontend/__init__.py +52 -0
  17. rtl_buddy_view/frontend/slang.py +23 -0
  18. rtl_buddy_view/frontend/verible.py +976 -0
  19. rtl_buddy_view/graph.py +157 -0
  20. rtl_buddy_view/offsets.py +48 -0
  21. rtl_buddy_view/overlays/__init__.py +361 -0
  22. rtl_buddy_view/overlays/axi_perf.py +59 -0
  23. rtl_buddy_view/overlays/clock.py +50 -0
  24. rtl_buddy_view/overlays/clock_tb.py +49 -0
  25. rtl_buddy_view/overlays/reset.py +41 -0
  26. rtl_buddy_view/overlays/wave.py +64 -0
  27. rtl_buddy_view/query.py +130 -0
  28. rtl_buddy_view/render/__init__.py +10 -0
  29. rtl_buddy_view/render/dot.py +1339 -0
  30. rtl_buddy_view/render/json_render.py +970 -0
  31. rtl_buddy_view/render/mermaid.py +268 -0
  32. rtl_buddy_view/render/tree.py +288 -0
  33. rtl_buddy_view/reset_annotations.py +397 -0
  34. rtl_buddy_view/tb_clock_map.py +409 -0
  35. rtl_buddy_view/viewer_bundle.py +34 -0
  36. rtl_buddy_view/wave_annotations.py +201 -0
  37. rtl_buddy_view-0.2.1.dist-info/METADATA +366 -0
  38. rtl_buddy_view-0.2.1.dist-info/RECORD +41 -0
  39. rtl_buddy_view-0.2.1.dist-info/WHEEL +4 -0
  40. rtl_buddy_view-0.2.1.dist-info/entry_points.txt +2 -0
  41. rtl_buddy_view-0.2.1.dist-info/licenses/LICENSE +28 -0
@@ -0,0 +1,5 @@
1
+ from rtl_buddy_view.cli import app
2
+
3
+
4
+ def main() -> None:
5
+ app()
@@ -0,0 +1,4 @@
1
+ from rtl_buddy_view import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,43 @@
1
+ """Deprecated alias for :mod:`rtl_buddy_view.cst_cache`.
2
+
3
+ The module was promoted to public API in view#109 — see
4
+ ``rtl_buddy_view.cst_cache``. This shim re-exports every public name
5
+ from the new location and emits a ``DeprecationWarning`` on import.
6
+ It will be removed in the next minor release.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import warnings as _warnings
12
+
13
+ from rtl_buddy_view.cst_cache import ( # noqa: F401 (re-exports)
14
+ _VERIBLE_VERSION_BY_BINARY,
15
+ _atomic_write_json,
16
+ _cache_path_for,
17
+ _query_version,
18
+ cache_root,
19
+ content_hash,
20
+ get_or_compute,
21
+ is_disabled,
22
+ verible_version,
23
+ )
24
+
25
+ __all__ = [
26
+ "_VERIBLE_VERSION_BY_BINARY",
27
+ "_atomic_write_json",
28
+ "_cache_path_for",
29
+ "_query_version",
30
+ "cache_root",
31
+ "content_hash",
32
+ "get_or_compute",
33
+ "is_disabled",
34
+ "verible_version",
35
+ ]
36
+
37
+ _warnings.warn(
38
+ "rtl_buddy_view._cst_cache is deprecated; import from "
39
+ "rtl_buddy_view.cst_cache instead. The underscore-prefixed module "
40
+ "will be removed in the next minor release.",
41
+ DeprecationWarning,
42
+ stacklevel=2,
43
+ )
@@ -0,0 +1,52 @@
1
+ """Filelist parsing.
2
+
3
+ Phase 1 supports the trivial subset: one absolute-or-relative file
4
+ path per line, blank lines and ``#`` / ``//`` comments ignored.
5
+ ``+incdir+`` and ``-y`` library directives, ``+define+`` macros, and
6
+ ``-f`` recursive includes will land alongside the broader extractor —
7
+ they're listed here so future work has a clear target but raise
8
+ ``FilelistError`` if encountered today rather than silently being
9
+ treated as filenames.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from pathlib import Path
15
+
16
+
17
+ class FilelistError(ValueError):
18
+ pass
19
+
20
+
21
+ _UNSUPPORTED_PREFIXES = ("+incdir+", "+define+", "-y", "-f")
22
+
23
+
24
+ def parse_filelist(path: Path) -> list[Path]:
25
+ text = path.read_text()
26
+ files: list[Path] = []
27
+ base = path.parent
28
+ for lineno, raw in enumerate(text.splitlines(), start=1):
29
+ line = raw.split("#", 1)[0].split("//", 1)[0].strip()
30
+ if not line:
31
+ continue
32
+ if line.startswith(_UNSUPPORTED_PREFIXES):
33
+ raise FilelistError(
34
+ f"{path}:{lineno}: directive {line.split()[0]!r} is not "
35
+ f"supported in Phase 1; see issue #1 for the planned "
36
+ f"filelist surface."
37
+ )
38
+ candidate = (base / line).resolve()
39
+ if candidate.is_dir():
40
+ # A directory entry is an include dir, not a source file —
41
+ # e.g. a ``+incdir+`` path that an upstream generator (rb
42
+ # hier's filelist strip) reduced to a bare path. The Verible
43
+ # frontend parses each source standalone and never consumes
44
+ # include dirs, so skip it rather than letting ``read_text``
45
+ # blow up with IsADirectoryError downstream.
46
+ continue
47
+ if not candidate.exists():
48
+ raise FilelistError(f"{path}:{lineno}: file does not exist: {candidate}")
49
+ files.append(candidate)
50
+ if not files:
51
+ raise FilelistError(f"{path}: filelist is empty")
52
+ return files
@@ -0,0 +1,23 @@
1
+ """Deprecated alias for :mod:`rtl_buddy_view.offsets`.
2
+
3
+ The module was promoted to public API in view#109 — see
4
+ ``rtl_buddy_view.offsets``. This shim re-exports :class:`OffsetIndex`
5
+ from the new location and emits a ``DeprecationWarning`` on import.
6
+ It will be removed in the next minor release.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import warnings as _warnings
12
+
13
+ from rtl_buddy_view.offsets import OffsetIndex # noqa: F401 (re-export)
14
+
15
+ __all__ = ["OffsetIndex"]
16
+
17
+ _warnings.warn(
18
+ "rtl_buddy_view._offsets is deprecated; import from "
19
+ "rtl_buddy_view.offsets instead. The underscore-prefixed module "
20
+ "will be removed in the next minor release.",
21
+ DeprecationWarning,
22
+ stacklevel=2,
23
+ )
@@ -0,0 +1,356 @@
1
+ """Pure-Python VCD reader for the Phase 8 wave snapshot overlay.
2
+
3
+ Reads a Value Change Dump file, walks to a target time T, and returns
4
+ the last-known value of every variable at-or-before T. Designed for
5
+ the offline-snapshot use case — single sample point, no time-series
6
+ output, no continuous streaming. The wave overlay
7
+ (:mod:`rtl_buddy_view.overlays.wave`) is the only consumer.
8
+
9
+ Why not pyvcd / pylibfst:
10
+
11
+ * pyvcd is read-write but its reader path is uncommon; bringing it
12
+ in just for one sample point would add a third-party dep and a
13
+ packaging concern (rtl-buddy-view stays at two runtime deps today:
14
+ typer + jsonschema).
15
+ * pylibfst requires a native libfst build, which we don't want to
16
+ inflict on a default install. FST support is a Phase-8 follow-up;
17
+ v1 ships VCD only.
18
+
19
+ The parser covers the subset of the VCD spec our fixtures + typical
20
+ simulator outputs (verilator, iverilog, modelsim) emit — header
21
+ sections, ``$timescale``, nested ``$scope module`` / ``$upscope``,
22
+ ``$var <type> <width> <id> <name>``, ``$enddefinitions``, scalar
23
+ value changes (``0!``, ``1!``, ``x!``, ``z!``), and vector changes
24
+ (``b<bits> <id>``). Real-number changes (``r<float> <id>``) and
25
+ strings (``s<string> <id>``) parse but render as a passthrough
26
+ literal. Arrays / structs aren't modelled; producers serialise them
27
+ to per-bit signals.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import re
33
+ from dataclasses import dataclass, field
34
+ from pathlib import Path
35
+
36
+
37
+ class VcdParseError(ValueError):
38
+ """Raised on malformed input.
39
+
40
+ The CLI catches this and surfaces ``overlay wave: <message>`` to
41
+ the user — distinct from a generic ``ValueError`` so the wave
42
+ overlay's loader can attach extra context without colliding with
43
+ other overlay errors.
44
+ """
45
+
46
+
47
+ #: Power-of-ten multipliers from each unit to fs. Anything outside this
48
+ #: set in a VCD ``$timescale`` is rejected — femtosecond resolution is
49
+ #: the canonical hub-side encoding so a sub-fs spec would lose precision
50
+ #: silently downstream.
51
+ _UNIT_TO_FS: dict[str, int] = {
52
+ "fs": 1,
53
+ "ps": 1_000,
54
+ "ns": 1_000_000,
55
+ "us": 1_000_000_000,
56
+ "ms": 1_000_000_000_000,
57
+ "s": 1_000_000_000_000_000,
58
+ }
59
+
60
+
61
+ # Time spec for CLI / overlay use. ``end`` is the sentinel for
62
+ # "sample at the last recorded time in the file".
63
+ _TIMESPEC_RE = re.compile(
64
+ r"^\s*(?P<num>\d+(?:\.\d+)?)\s*(?P<unit>fs|ps|ns|us|ms|s)\s*$",
65
+ re.IGNORECASE,
66
+ )
67
+
68
+
69
+ def parse_time_spec(spec: str) -> int | None:
70
+ """Convert ``"12500ns"`` / ``"100us"`` / ``"end"`` to fs.
71
+
72
+ Returns the time in femtoseconds, or ``None`` for the ``end``
73
+ sentinel (the reader expands that to the last recorded time at
74
+ sample time, when it actually knows the file's tail).
75
+
76
+ Raises :class:`VcdParseError` on garbage input.
77
+ """
78
+ if spec.strip().lower() == "end":
79
+ return None
80
+ m = _TIMESPEC_RE.match(spec)
81
+ if m is None:
82
+ raise VcdParseError(
83
+ f"unrecognised time spec {spec!r}; expected NUMBER+UNIT "
84
+ f"(e.g. 12500ns, 1.5us) or the literal 'end'."
85
+ )
86
+ num = float(m.group("num"))
87
+ unit = m.group("unit").lower()
88
+ return int(round(num * _UNIT_TO_FS[unit]))
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class VcdSnapshot:
93
+ """Result of sampling a VCD at a specific time.
94
+
95
+ * ``t_fs`` — the time the sample was taken at, in fs. May not equal
96
+ the user's requested time exactly: the parser walks change records
97
+ and stops when the next ``#T`` would exceed the target, so the
98
+ returned ``t_fs`` is the highest in-range change point. (When the
99
+ user asked for ``end``, this is the last record in the file.)
100
+ * ``signals`` — ``hier_path → SV-literal value``. Values use the
101
+ surfer / handle_bits convention (binary literal characters,
102
+ including ``x`` / ``z``) so the renderer can format consistently
103
+ with the live ``wave_values_changed`` path.
104
+ * ``timescale_fs`` — the file's tick→fs multiplier. Useful for
105
+ tests + error messages; not consumed by the overlay.
106
+ * ``declared`` — every variable seen in a ``$var`` declaration,
107
+ including ones with no transitions before ``t_fs``. The overlay
108
+ paints ``??`` (or skips) for these.
109
+ """
110
+
111
+ t_fs: int
112
+ signals: dict[str, str]
113
+ timescale_fs: int = 1
114
+ declared: set[str] = field(default_factory=set)
115
+
116
+
117
+ def sample_vcd(path: Path, target_fs: int | None) -> VcdSnapshot:
118
+ """Read ``path`` and return the values of every variable at
119
+ ``target_fs`` (in femtoseconds). When ``target_fs`` is ``None``,
120
+ sample at the file's last recorded time.
121
+
122
+ Raises :class:`VcdParseError` on malformed input. Files with no
123
+ ``$var`` declarations raise rather than returning an empty
124
+ snapshot — silent emptiness almost always indicates the user
125
+ pointed at the wrong file.
126
+ """
127
+
128
+ with path.open("r", encoding="utf-8", errors="replace") as fh:
129
+ return _parse(fh.read(), target_fs)
130
+
131
+
132
+ def _parse(text: str, target_fs: int | None) -> VcdSnapshot:
133
+ # Two-phase parser. Phase 1 walks $-keyword blocks for the header
134
+ # (timescale, $scope, $var, …) until ``$enddefinitions $end``.
135
+ # Phase 2 walks the body line-by-line for ``#T`` timestamps and
136
+ # value-change records. Splitting on $end inside the header tokens
137
+ # lets us collapse multi-line blocks ($comment, $version, …) into
138
+ # a single bracketed unit.
139
+ timescale_fs = 1
140
+ # VCD's id-code is a (1:N) reference: a single id can be declared
141
+ # in multiple $var lines when the same wire is exposed under
142
+ # several hierarchy paths (e.g. tb.dut.clk + tb.dut.u_ff.clk both
143
+ # bound to the same testbench-side wire). Aliasing is part of the
144
+ # spec, so we track every path each id resolves to.
145
+ id_to_paths: dict[str, list[str]] = {}
146
+ id_to_width: dict[str, int] = {}
147
+ declared: set[str] = set()
148
+ scope_stack: list[str] = []
149
+
150
+ # Split into tokens for the header pass, then locate the boundary.
151
+ # The body starts right after the ``$end`` that closes
152
+ # ``$enddefinitions``.
153
+ end_def_idx = text.find("$enddefinitions")
154
+ if end_def_idx < 0:
155
+ raise VcdParseError("no $enddefinitions block found; not a VCD file?")
156
+ # Step past ``$enddefinitions`` itself before scanning for the
157
+ # closing ``$end`` — ``$enddefinitions`` is a prefix of ``$end``
158
+ # so a naive ``find('$end', end_def_idx)`` lands on the keyword
159
+ # itself and the body / header split collapses.
160
+ after_end_def = text.find("$end", end_def_idx + len("$enddefinitions"))
161
+ if after_end_def < 0:
162
+ raise VcdParseError("$enddefinitions present but not closed by $end.")
163
+ header_text = text[: after_end_def + len("$end")]
164
+ body_text = text[after_end_def + len("$end") :]
165
+
166
+ header_tokens = header_text.split()
167
+ i = 0
168
+ n = len(header_tokens)
169
+ while i < n:
170
+ tok = header_tokens[i]
171
+ if tok == "$timescale":
172
+ block, j = _block_to_end(header_tokens, i + 1)
173
+ if not block:
174
+ raise VcdParseError("empty $timescale block.")
175
+ num, unit = _parse_timescale_block(block)
176
+ timescale_fs = num * _UNIT_TO_FS[unit]
177
+ i = j
178
+ elif tok == "$scope":
179
+ block, j = _block_to_end(header_tokens, i + 1)
180
+ # ``$scope module <name> $end`` — the last word is the name.
181
+ if not block:
182
+ raise VcdParseError("$scope block missing name.")
183
+ scope_stack.append(block[-1])
184
+ i = j
185
+ elif tok == "$upscope":
186
+ _, j = _block_to_end(header_tokens, i + 1)
187
+ if scope_stack:
188
+ scope_stack.pop()
189
+ i = j
190
+ elif tok == "$var":
191
+ block, j = _block_to_end(header_tokens, i + 1)
192
+ # ``$var <type> <width> <id> <name> [<slice>] $end``
193
+ if len(block) < 4:
194
+ raise VcdParseError(f"$var with too few fields: {block!r}")
195
+ try:
196
+ width = int(block[1])
197
+ except ValueError as exc:
198
+ raise VcdParseError(f"non-integer width in $var: {block!r}") from exc
199
+ vcd_id = block[2]
200
+ name = block[3]
201
+ hier_path = ".".join(scope_stack + [name])
202
+ id_to_paths.setdefault(vcd_id, []).append(hier_path)
203
+ # Width should be consistent across aliased paths; the
204
+ # spec doesn't allow conflicting widths on the same id.
205
+ # We trust the first declaration.
206
+ id_to_width.setdefault(vcd_id, width)
207
+ declared.add(hier_path)
208
+ i = j
209
+ elif tok == "$enddefinitions":
210
+ _, j = _block_to_end(header_tokens, i + 1)
211
+ i = j
212
+ # End of header; body comes from ``body_text``.
213
+ break
214
+ elif tok.startswith("$"):
215
+ # $comment / $date / $version / inline $dumpvars in header,
216
+ # anything else we don't model — drain to matching $end.
217
+ _, j = _block_to_end(header_tokens, i + 1)
218
+ i = j
219
+ else:
220
+ # Bare token in the header (whitespace, stray identifier
221
+ # from a malformed file). Skip.
222
+ i += 1
223
+
224
+ if not declared:
225
+ raise VcdParseError(
226
+ "no $var declarations found before $enddefinitions; is this "
227
+ "really a VCD file?"
228
+ )
229
+
230
+ # ----- body pass --------------------------------------------------
231
+ #
232
+ # We track the current value per id and the latest fully-applied
233
+ # timestamp. A line beginning with ``#`` is a timestamp marker; a
234
+ # change record applies on top of the prior timestamp. We stop at
235
+ # the first ``#T`` whose value exceeds the target.
236
+ current: dict[str, str] = {}
237
+ last_emitted_fs = 0
238
+
239
+ for raw in body_text.splitlines():
240
+ line = raw.strip()
241
+ if not line:
242
+ continue
243
+ if line.startswith("$"):
244
+ # $dumpvars / $dumpall / $dumpon / $dumpoff blocks wrap a
245
+ # batch of value changes. We process the inner records as
246
+ # usual on subsequent iterations; the $-keywords + their
247
+ # trailing $end are no-ops at this layer.
248
+ continue
249
+ if line[0] == "#":
250
+ try:
251
+ tick = int(line[1:])
252
+ except ValueError as exc:
253
+ raise VcdParseError(f"non-integer timestamp on line {line!r}") from exc
254
+ new_t_fs = tick * timescale_fs
255
+ if target_fs is not None and new_t_fs > target_fs:
256
+ break
257
+ last_emitted_fs = new_t_fs
258
+ continue
259
+ _apply_change(line, id_to_paths, id_to_width, current)
260
+
261
+ if target_fs is None:
262
+ t_fs = last_emitted_fs
263
+ else:
264
+ t_fs = min(target_fs, last_emitted_fs)
265
+
266
+ # Expand id-keyed `current` to all aliased hierarchy paths.
267
+ signals: dict[str, str] = {}
268
+ for ident, value in current.items():
269
+ for path in id_to_paths.get(ident, ()):
270
+ signals[path] = value
271
+
272
+ return VcdSnapshot(
273
+ t_fs=t_fs,
274
+ signals=signals,
275
+ timescale_fs=timescale_fs,
276
+ declared=declared,
277
+ )
278
+
279
+
280
+ def _apply_change(
281
+ line: str,
282
+ id_to_paths: dict[str, list[str]],
283
+ id_to_width: dict[str, int],
284
+ current: dict[str, str],
285
+ ) -> None:
286
+ ch0 = line[0]
287
+ if ch0 in "01xzXZuU-lLhHwW":
288
+ value = ch0.lower()
289
+ ident = line[1:]
290
+ if ident in id_to_paths:
291
+ current[ident] = value
292
+ return
293
+ if ch0 in "bB":
294
+ sp = line.find(" ")
295
+ if sp < 0:
296
+ return
297
+ bits = line[1:sp]
298
+ ident = line[sp + 1 :]
299
+ if ident in id_to_paths:
300
+ w = id_to_width[ident]
301
+ # VCD permits truncated leading zeros; pad to declared
302
+ # width so the consumer can render fixed-width hex without
303
+ # re-deriving the bit count.
304
+ if len(bits) < w:
305
+ pad = bits[0] if (bits and bits[0] in "xzXZ") else "0"
306
+ bits = bits.rjust(w, pad)
307
+ elif len(bits) > w:
308
+ bits = bits[-w:]
309
+ current[ident] = bits.lower()
310
+ return
311
+ if ch0 in "rR":
312
+ sp = line.find(" ")
313
+ if sp < 0:
314
+ return
315
+ real_lit = line[1:sp]
316
+ ident = line[sp + 1 :]
317
+ if ident in id_to_paths:
318
+ current[ident] = f"r{real_lit}"
319
+ return
320
+ if ch0 in "sS":
321
+ sp = line.find(" ")
322
+ if sp < 0:
323
+ return
324
+ str_lit = line[1:sp]
325
+ ident = line[sp + 1 :]
326
+ if ident in id_to_paths:
327
+ current[ident] = f"s{str_lit}"
328
+
329
+
330
+ def _block_to_end(tokens: list[str], start: int) -> tuple[list[str], int]:
331
+ """Read tokens from ``start`` until ``$end``; return ``(words, idx_after)``.
332
+
333
+ ``idx_after`` points past the matched ``$end`` so the caller can
334
+ advance ``i = idx_after`` and continue.
335
+ """
336
+ out: list[str] = []
337
+ j = start
338
+ while j < len(tokens):
339
+ if tokens[j] == "$end":
340
+ return out, j + 1
341
+ out.append(tokens[j])
342
+ j += 1
343
+ raise VcdParseError("reached EOF inside a $-block (missing $end).")
344
+
345
+
346
+ def _parse_timescale_block(words: list[str]) -> tuple[int, str]:
347
+ """Pull ``(num, unit)`` from a ``$timescale`` block's words.
348
+
349
+ Tolerates the unit being a separate token (``1 ns``) or fused
350
+ (``1ns``).
351
+ """
352
+ blob = "".join(words)
353
+ m = _TIMESPEC_RE.match(blob)
354
+ if m is None:
355
+ raise VcdParseError(f"unrecognised $timescale: {blob!r}")
356
+ return int(round(float(m.group("num")))), m.group("unit").lower()