Skip to content

Commit 162ec1b

Browse files
committed
feat(oeb): parse cvc.oeb.report into structured CheckResult.report (1.3.0)
Adds cf_precheck.checks._oeb_report with dual parsers for caravel/caravan and openframe report layouts. Oeb.run() now locates the emitted report, parses it with a design-type hint, and attaches a structured dict plus a one-line summary to the CheckResult so the platform UI can render per-GPIO rows, ERR/WARN badges, and messages without re-parsing. Runner picks up check.report / check.details via getattr so other checks can opt into the same pattern later. Report is always optional: parse failures surface as {parse_error: ...} and never change the OEB check's pass/fail status. Includes 15 unit tests covering caravel, caravan hint, openframe dm resolution, no-issues path, CRLF tolerance, empty file, and missing-report fallback, plus pytest config pointing at src/ so `pytest` runs out of the box. Made-with: Cursor
1 parent 69b2d3f commit 162ec1b

9 files changed

Lines changed: 646 additions & 5 deletions

File tree

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,7 @@ path = "src/cf_precheck/__init__.py"
2727

2828
[tool.hatch.build.targets.wheel]
2929
packages = ["src/cf_precheck"]
30+
31+
[tool.pytest.ini_options]
32+
pythonpath = ["src"]
33+
testpaths = ["tests"]

src/cf_precheck/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "1.2.0"
1+
__version__ = "1.3.0"
Lines changed: 374 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,374 @@
1+
"""Parse cvc.oeb.report into a structured dict for UI consumption.
2+
3+
The backend OEB scripts (see cf_precheck/be_checks/run_oeb_check and
4+
cf_precheck/be_checks/run_openframe_check) emit a human-readable report with
5+
two distinct formats:
6+
7+
caravel / caravan (padring-adjusted gpio, user index, analog index):
8+
9+
" gpio/user/analog | in | out | analog | oeb min/sim/max | configuration"
10+
" 35 / 35 / 28 | 10 | 20 | | vssd1/vssd1/vssd1 | USER_STD_OUTPUT 2 warnings/errors"
11+
12+
openframe (raw gpio index, configuration bit string, resolved mode):
13+
14+
" gpio | in | out | analog | oeb min/sim/max | Configuration (dm[2:0] vtrip slow analog[pol,sel,en] ib input_dis holdover)"
15+
" 35 | 10 | 20 | | vssd1/vssd1/vssd1 | 01100000000 -> USER_STD_OUTPUT 2 warnings/errors"
16+
17+
Both formats end with either
18+
19+
"No warnings or errors detected"
20+
21+
or
22+
23+
"*** Detected the following warnings and/or errors: ***"
24+
"GPIO 35: ERROR: user input connection to user output gpio - undriven"
25+
"GPIO 36: Warning: user output fixed at vssd1"
26+
...
27+
28+
The parser is intentionally permissive: unknown lines are skipped, and any
29+
failure returns a dict with a ``parse_error`` key so the UI can still show a
30+
warning without losing the overall check status.
31+
"""
32+
33+
from __future__ import annotations
34+
35+
import logging
36+
import re
37+
from pathlib import Path
38+
from typing import Any, Optional
39+
40+
41+
CARAVEL_HEADER_PREFIX = "gpio/user/analog"
42+
OPENFRAME_HEADER_PREFIX = "gpio |"
43+
OPENFRAME_HEADER_MARKER = "Configuration (dm[2:0]"
44+
45+
_CARAVEL_ROW_RE = re.compile(
46+
r"""^\s*
47+
(?P<gpio>\d+)\s*/\s*
48+
(?P<user>\d+)\s*/\s*
49+
(?P<analog>\d+|)\s*\|\s*
50+
(?P<in>\S*)\s*\|\s*
51+
(?P<out>\S*)\s*\|\s*
52+
(?P<analog_count>\S*)\s*\|\s*
53+
(?P<oeb_min>\S*)\s*/\s*(?P<oeb_sim>\S*)\s*/\s*(?P<oeb_max>\S*)\s*\|\s*
54+
(?P<tail>.*)$
55+
""",
56+
re.VERBOSE,
57+
)
58+
59+
_OPENFRAME_ROW_RE = re.compile(
60+
r"""^\s*
61+
(?P<gpio>\d+)\s*\|\s*
62+
(?P<in>\S*)\s*\|\s*
63+
(?P<out>\S*)\s*\|\s*
64+
(?P<analog_count>\S*)\s*\|\s*
65+
(?P<oeb_min>\S*)\s*/\s*(?P<oeb_sim>\S*)\s*/\s*(?P<oeb_max>\S*)\s*\|\s*
66+
(?P<tail>.*)$
67+
""",
68+
re.VERBOSE,
69+
)
70+
71+
_MSG_RE = re.compile(
72+
r"""^\s*GPIO\s+(?P<gpio>\d+)\s*:\s*
73+
(?P<severity>ERROR|Warning)\s*:\s*
74+
(?P<text>.+?)\s*$
75+
""",
76+
re.VERBOSE | re.IGNORECASE,
77+
)
78+
79+
_WARN_COUNT_RE = re.compile(r"(\d+)\s+warnings?/errors?", re.IGNORECASE)
80+
81+
_OPENFRAME_DM_BITS = 3
82+
_OPENFRAME_CONFIG_LEN = 11
83+
84+
85+
def _clean(line: str) -> str:
86+
return line.rstrip("\r\n").rstrip()
87+
88+
89+
def _maybe_int(value: str) -> Optional[int]:
90+
value = value.strip()
91+
if not value:
92+
return None
93+
try:
94+
return int(value)
95+
except ValueError:
96+
return None
97+
98+
99+
def _parse_tail_caravel(tail: str) -> tuple[str, Optional[int]]:
100+
"""Split trailing mode cell into (configuration, warning_count).
101+
102+
Caravel/caravan reports render the configuration as a single mode name
103+
such as ``USER_STD_OUTPUT`` followed optionally by ``"N warnings/errors"``
104+
and any additional free-text warnings printed for the "unknown mode" case.
105+
"""
106+
tail = tail.strip()
107+
warning_count: Optional[int] = None
108+
match = _WARN_COUNT_RE.search(tail)
109+
if match:
110+
warning_count = int(match.group(1))
111+
tail = (tail[: match.start()] + tail[match.end():]).strip()
112+
return tail, warning_count
113+
114+
115+
def _parse_tail_openframe(tail: str) -> tuple[Optional[str], Optional[str], Optional[int]]:
116+
"""Split trailing column into (config_bits, resolved_mode, warning_count).
117+
118+
Openframe rows look like ``"01100000000 -> USER_STD_OUTPUT 2 warnings/errors"``.
119+
"""
120+
tail = tail.strip()
121+
warning_count: Optional[int] = None
122+
match = _WARN_COUNT_RE.search(tail)
123+
if match:
124+
warning_count = int(match.group(1))
125+
tail = (tail[: match.start()] + tail[match.end():]).strip()
126+
127+
config_bits: Optional[str] = None
128+
resolved: Optional[str] = None
129+
if "->" in tail:
130+
left, right = tail.split("->", 1)
131+
config_bits = left.strip() or None
132+
resolved = right.strip() or None
133+
else:
134+
# All-bits-known-but-mode-unknown case still prints a config.
135+
resolved = tail or None
136+
137+
if resolved and resolved.upper() == "UNKNOWN":
138+
resolved = None
139+
return config_bits, resolved, warning_count
140+
141+
142+
def _detect_format(lines: list[str]) -> Optional[str]:
143+
for line in lines:
144+
stripped = line.strip()
145+
if not stripped:
146+
continue
147+
if CARAVEL_HEADER_PREFIX in stripped:
148+
return "caravel"
149+
if OPENFRAME_HEADER_MARKER in stripped:
150+
return "openframe"
151+
if stripped.startswith(OPENFRAME_HEADER_PREFIX) and "Configuration" in stripped:
152+
return "openframe"
153+
return None
154+
155+
156+
def _parse_messages(lines: list[str]) -> list[dict[str, Any]]:
157+
messages: list[dict[str, Any]] = []
158+
for raw in lines:
159+
line = _clean(raw)
160+
if not line:
161+
continue
162+
match = _MSG_RE.match(line)
163+
if not match:
164+
continue
165+
severity = match.group("severity")
166+
normalized = "error" if severity.upper() == "ERROR" else "warning"
167+
messages.append({
168+
"gpio": int(match.group("gpio")),
169+
"severity": normalized,
170+
"text": match.group("text").strip(),
171+
})
172+
return messages
173+
174+
175+
def _split_gpio_rows_and_messages(lines: list[str]) -> tuple[list[str], list[str], bool]:
176+
"""Return (row_lines, message_lines, has_explicit_no_issues_marker).
177+
178+
Rows appear after the header and before either the "No warnings or errors"
179+
line or the "*** Detected ..." banner. Messages appear after the banner.
180+
"""
181+
row_lines: list[str] = []
182+
message_lines: list[str] = []
183+
header_seen = False
184+
in_messages = False
185+
no_issues_seen = False
186+
187+
for raw in lines:
188+
line = _clean(raw)
189+
stripped = line.strip()
190+
if not header_seen:
191+
if (
192+
CARAVEL_HEADER_PREFIX in stripped
193+
or OPENFRAME_HEADER_MARKER in stripped
194+
or (stripped.startswith(OPENFRAME_HEADER_PREFIX) and "Configuration" in stripped)
195+
):
196+
header_seen = True
197+
continue
198+
199+
if not stripped:
200+
continue
201+
202+
if stripped.startswith("***") and "Detected" in stripped:
203+
in_messages = True
204+
continue
205+
206+
if stripped == "No warnings or errors detected":
207+
no_issues_seen = True
208+
in_messages = True
209+
continue
210+
211+
if in_messages:
212+
message_lines.append(line)
213+
else:
214+
row_lines.append(line)
215+
216+
return row_lines, message_lines, no_issues_seen
217+
218+
219+
def _parse_caravel(row_lines: list[str]) -> list[dict[str, Any]]:
220+
gpios: list[dict[str, Any]] = []
221+
for line in row_lines:
222+
match = _CARAVEL_ROW_RE.match(line)
223+
if not match:
224+
continue
225+
configuration, warning_count = _parse_tail_caravel(match.group("tail"))
226+
gpios.append({
227+
"gpio": int(match.group("gpio")),
228+
"user_index": int(match.group("user")),
229+
"analog_index": _maybe_int(match.group("analog")),
230+
"in": _maybe_int(match.group("in")),
231+
"out": match.group("out").strip() or None,
232+
"analog": _maybe_int(match.group("analog_count")),
233+
"oeb_min": match.group("oeb_min").strip() or None,
234+
"oeb_sim": match.group("oeb_sim").strip() or None,
235+
"oeb_max": match.group("oeb_max").strip() or None,
236+
"configuration": configuration or None,
237+
"resolved_mode": configuration or None,
238+
"warning_count": warning_count,
239+
})
240+
return gpios
241+
242+
243+
def _parse_openframe(row_lines: list[str]) -> list[dict[str, Any]]:
244+
gpios: list[dict[str, Any]] = []
245+
for line in row_lines:
246+
match = _OPENFRAME_ROW_RE.match(line)
247+
if not match:
248+
continue
249+
config_bits, resolved, warning_count = _parse_tail_openframe(match.group("tail"))
250+
dm = config_bits[:_OPENFRAME_DM_BITS] if config_bits and len(config_bits) >= _OPENFRAME_DM_BITS else None
251+
gpios.append({
252+
"gpio": int(match.group("gpio")),
253+
"user_index": int(match.group("gpio")),
254+
"analog_index": None,
255+
"in": _maybe_int(match.group("in")),
256+
"out": match.group("out").strip() or None,
257+
"analog": _maybe_int(match.group("analog_count")),
258+
"oeb_min": match.group("oeb_min").strip() or None,
259+
"oeb_sim": match.group("oeb_sim").strip() or None,
260+
"oeb_max": match.group("oeb_max").strip() or None,
261+
"configuration": config_bits,
262+
"dm": dm,
263+
"resolved_mode": resolved,
264+
"warning_count": warning_count,
265+
})
266+
return gpios
267+
268+
269+
def _annotate_counts(
270+
gpios: list[dict[str, Any]],
271+
messages: list[dict[str, Any]],
272+
) -> None:
273+
"""Populate per-GPIO error/warning counts from the message list.
274+
275+
Prefer counts derived from messages over the awk "N warnings/errors" token
276+
because the latter combines both severities without distinguishing them.
277+
"""
278+
by_gpio: dict[int, dict[str, int]] = {}
279+
for msg in messages:
280+
tally = by_gpio.setdefault(msg["gpio"], {"errors": 0, "warnings": 0})
281+
if msg["severity"] == "error":
282+
tally["errors"] += 1
283+
else:
284+
tally["warnings"] += 1
285+
for row in gpios:
286+
tally = by_gpio.get(row["gpio"], {"errors": 0, "warnings": 0})
287+
row["error_count"] = tally["errors"]
288+
row["warning_count"] = tally["warnings"]
289+
290+
291+
def parse_report_text(text: str, *, design_type: Optional[str] = None) -> dict[str, Any]:
292+
"""Parse raw cvc.oeb.report text into a structured dict."""
293+
if not text.strip():
294+
return {"parse_error": "empty report"}
295+
296+
lines = text.splitlines()
297+
detected = _detect_format(lines)
298+
if detected is None:
299+
return {"parse_error": "unrecognized report format"}
300+
301+
row_lines, message_lines, no_issues = _split_gpio_rows_and_messages(lines)
302+
messages = _parse_messages(message_lines)
303+
304+
if detected == "caravel":
305+
gpios = _parse_caravel(row_lines)
306+
rendered_type = "caravel"
307+
# Heuristic: caravan reports keep the same header but apply a +11 offset
308+
# for gpios above 13 (see run_oeb_check). We cannot reliably distinguish
309+
# caravel from caravan from the report alone, so fall back to the
310+
# caller-provided design_type when present.
311+
if design_type in {"caravan", "analog", "caravel"}:
312+
rendered_type = design_type if design_type in {"caravel", "caravan"} else (
313+
"caravan" if design_type == "analog" else "caravel"
314+
)
315+
else:
316+
gpios = _parse_openframe(row_lines)
317+
rendered_type = "openframe"
318+
319+
_annotate_counts(gpios, messages)
320+
321+
errors = sum(1 for m in messages if m["severity"] == "error")
322+
warnings = sum(1 for m in messages if m["severity"] == "warning")
323+
324+
return {
325+
"design_type": rendered_type,
326+
"gpios": gpios,
327+
"messages": messages,
328+
"summary": {
329+
"total": len(gpios),
330+
"errors": errors,
331+
"warnings": warnings,
332+
"no_issues_banner": no_issues,
333+
},
334+
}
335+
336+
337+
def parse_report_file(path: Path, *, design_type: Optional[str] = None) -> dict[str, Any]:
338+
"""Read and parse cvc.oeb.report from disk.
339+
340+
Returns a dict that always includes a ``report_relpath`` pointing at the
341+
report location (relative-ish — actually the absolute filename the caller
342+
passed in). On any IO / parse failure, a ``parse_error`` key is set
343+
instead of raising, so the caller never has to guard against an exception.
344+
"""
345+
payload: dict[str, Any] = {"report_relpath": str(path)}
346+
try:
347+
text = path.read_text(encoding="utf-8", errors="replace")
348+
except OSError as exc:
349+
payload["parse_error"] = f"could not read report: {exc}"
350+
logging.debug("OEB report read failed: %s", exc)
351+
return payload
352+
353+
try:
354+
parsed = parse_report_text(text, design_type=design_type)
355+
except Exception as exc: # pragma: no cover - defensive
356+
payload["parse_error"] = f"parser crashed: {exc}"
357+
logging.debug("OEB report parse crashed: %s", exc, exc_info=True)
358+
return payload
359+
360+
payload.update(parsed)
361+
return payload
362+
363+
364+
def one_line_summary(report: dict[str, Any]) -> str:
365+
"""Human-readable summary to stash in CheckResult.details."""
366+
if "parse_error" in report:
367+
return f"OEB report unavailable: {report['parse_error']}"
368+
summary = report.get("summary") or {}
369+
total = summary.get("total", 0)
370+
errors = summary.get("errors", 0)
371+
warnings = summary.get("warnings", 0)
372+
if errors == 0 and warnings == 0:
373+
return f"0 errors, 0 warnings across {total} GPIOs"
374+
return f"{errors} error{'s' if errors != 1 else ''}, {warnings} warning{'s' if warnings != 1 else ''} across {total} GPIOs"

0 commit comments

Comments
 (0)