Merge pull request #1530 from saba8814/test/xpath-coverage-heatmap

Added support for xpath coverage report generation
This commit is contained in:
Mattias Walström
2026-06-15 16:50:38 +02:00
committed by GitHub
12 changed files with 642 additions and 6 deletions
+12
View File
@@ -143,3 +143,15 @@ jobs:
with:
name: test-report
path: output/images/test-report.pdf
- name: Generate XPath Coverage Report for ${{ env.TARGET }}
if: always()
run: |
make test-dir="$(pwd)/$TEST_PATH" xpath-coverage-report
- name: Upload XPath Coverage Report as Artifact
if: always()
uses: actions/upload-artifact@v7
with:
name: xpath-coverage-report
path: output/images/xpath-coverage-report.pdf
+1 -1
View File
@@ -2,7 +2,7 @@
# shellcheck disable=SC2034,SC2154
# Current container image
INFIX_TEST=ghcr.io/kernelkit/infix-test:2.9
INFIX_TEST=ghcr.io/kernelkit/infix-test:2.10
ixdir=$(readlink -f "$testdir/..")
logdir=$(readlink -f "$testdir/.log")
+4 -1
View File
@@ -11,6 +11,7 @@ RUN apk add --no-cache \
ethtool \
fakeroot \
file \
font-dejavu \
gcc \
git \
graphviz \
@@ -24,6 +25,7 @@ RUN apk add --no-cache \
nmap \
openssh-client \
openssl \
pandoc-cli \
python3-dev \
qemu-img \
qemu-system-x86_64 \
@@ -33,7 +35,8 @@ RUN apk add --no-cache \
squashfs-tools \
sshpass \
tcpdump \
tshark
tshark \
weasyprint
ARG MTOOL_VERSION="3.0"
RUN wget https://github.com/troglobit/mtools/releases/download/v3.0/mtools-$MTOOL_VERSION.tar.gz -O /tmp/mtools-$MTOOL_VERSION.tar.gz
+1
View File
@@ -1,4 +1,5 @@
libyang==2.7.1
pyang==2.7.1
netconf_client==3.1.1
netifaces==0.11.0
networkx==3.1
+95
View File
@@ -0,0 +1,95 @@
"""XPath coverage tracker for infamy test runs.
Writes covered xpaths to $NINEPM_LOG_PATH/xpath_coverage.log, one per line.
Call track_xpath(), track_module(), or track_dict() from transport methods.
"""
import os
import threading
_lock = threading.Lock()
_tracked: set[str] = set()
def _log_path() -> str | None:
log_dir = os.environ.get("NINEPM_LOG_PATH")
if not log_dir:
return None
return os.path.join(log_dir, "xpath_coverage.log")
def _flush(xpaths: list[str]) -> None:
path = _log_path()
if not path or not xpaths:
return
try:
with open(path, "a", encoding="utf-8") as f:
for x in xpaths:
f.write(x + "\n")
except OSError:
pass
def _record(xpaths: list[str]) -> None:
new: list[str] = []
with _lock:
for x in xpaths:
if x not in _tracked:
_tracked.add(x)
new.append(x)
_flush(new)
def _normalize(xpath: str) -> str:
"""Strip module prefix from every segment except the root.
pyang and the CSV always use /mod:top/child (no mid-path prefixes).
Tests sometimes pass /mod:top/other-mod:child; we canonicalize here.
"""
if not xpath or ":" not in xpath:
return xpath
segs = xpath.lstrip("/").split("/")
out = []
for i, seg in enumerate(segs):
out.append(seg if i == 0 else seg.split(":", 1)[-1])
return "/" + "/".join(out)
def track_xpath(xpath: str | None) -> None:
"""Record a direct xpath access (get_data, delete_xpath, call_action, ...)."""
if xpath:
_record([_normalize(xpath)])
def track_module(modname: str | None) -> None:
"""Record a module-level access (call_dict with no specific path)."""
if modname:
_record([f"/{modname}:*"])
def track_dict(modname: str | None, data: object) -> None:
"""Record xpaths implied by a config/rpc dict (put_config_dicts, ...)."""
if not modname or not isinstance(data, dict):
return
_record(list(_dict_to_xpaths(modname, data)))
def _dict_to_xpaths(modname: str, data: object, prefix: str = "") -> set[str]:
xpaths: set[str] = set()
if isinstance(data, dict):
for key, value in data.items():
node = key.split(":", 1)[-1]
if not prefix:
xpath = f"/{modname}:{node}"
else:
xpath = f"{prefix}/{node}"
xpaths.add(xpath)
xpaths.update(_dict_to_xpaths(modname, value, xpath))
elif isinstance(data, list):
for item in data:
xpaths.update(_dict_to_xpaths(modname, item, prefix))
return xpaths
+10 -1
View File
@@ -17,7 +17,7 @@ import netconf_client.connect
import netconf_client.ncclient
from infamy.transport import Transport,infer_put_dict
from netconf_client.error import RpcError
from . import env, netutil
from . import env, netutil, coverage
def netconf_syn(addr):
@@ -249,6 +249,7 @@ class Device(Transport):
})
def get(self, xpath, parse=True):
coverage.track_xpath(xpath)
xpath_filter = self._build_xpath_filter(xpath)
response = self.ncc.get(filter=xpath_filter)
return self._parse_response(response, parse)
@@ -263,6 +264,7 @@ class Device(Transport):
return data.print_dict()
def get_data(self, xpath=None, parse=True):
coverage.track_xpath(xpath)
xpath_filter = self._build_xpath_filter(xpath, get_data_xpath=True)
response = self.ncc.get_data(datastore="ds:operational", filter=xpath_filter)
parsed_data = self._parse_response(response, parse)
@@ -273,6 +275,7 @@ class Device(Transport):
return parsed_data
def get_config(self, xpath):
coverage.track_xpath(xpath)
xpath_filter = self._build_xpath_filter(xpath)
response = self.ncc.get_config(source="running", filter=xpath_filter)
return self._parse_response(response, True)
@@ -317,6 +320,8 @@ class Device(Transport):
"""PUT full configuration of all models to running-config"""
config = ""
infer_put_dict(self.name, models)
for mod, data in models.items():
coverage.track_dict(mod, data)
for model in models.keys():
try:
@@ -349,6 +354,7 @@ class Device(Transport):
f"Available models can be checked with get_schema_list()") from None
lyd = mod.parse_data_dict(edit, no_state=True, validate=False)
config = lyd.print_mem("xml", with_siblings=True, pretty=False)
coverage.track_dict(modname, edit)
# print(f"Send new XML config: {config}")
return self.put_config(config, retries=retries)
@@ -358,6 +364,7 @@ class Device(Transport):
def call_dict(self, modname, call):
"""Call RPC, Python dictionary version"""
coverage.track_dict(modname, call)
try:
mod = self.ly.get_module(modname)
except libyang.util.LibyangError:
@@ -374,6 +381,7 @@ class Device(Transport):
the action's input leaves. Defaults to an empty input for
actions that take no parameters.
"""
coverage.track_xpath(xpath)
action={}
pattern = r"^/(?P<module>[^:]+):(?P<path>[^/]+)"
match = re.search(pattern, xpath)
@@ -416,6 +424,7 @@ class Device(Transport):
f.write(data.schema)
def delete_xpath(self, xpath):
coverage.track_xpath(xpath)
# Split out the model and the container from xpath'
pattern = r"^/(?P<module>[^:]+):(?P<path>[^/]+)"
match = re.search(pattern, xpath)
+10 -1
View File
@@ -11,7 +11,7 @@ from requests.auth import HTTPBasicAuth
from urllib3.exceptions import InsecureRequestWarning
from dataclasses import dataclass
from infamy.transport import Transport, infer_put_dict
from . import env
from . import env, coverage
# We know we have a self-signed certificate, silence warning about it
warnings.simplefilter('ignore', InsecureRequestWarning)
@@ -258,6 +258,7 @@ class Device(Transport):
def get_config_dict(self, xpath):
"""Get all configuration matching @xpath as dictionary"""
coverage.track_xpath(xpath)
# Strip leading slash if present (for compatibility with NETCONF xpath style)
xpath = xpath.lstrip('/')
@@ -295,6 +296,8 @@ class Device(Transport):
retries: Number of retry attempts on failure (default 3)
"""
infer_put_dict(self.name, models)
for mod, data in models.items():
coverage.track_dict(mod, data)
# Copy running to candidate first (to preserve existing config)
self.copy("running", "candidate")
@@ -343,6 +346,7 @@ class Device(Transport):
# Copy candidate to running (acts as "commit", triggers sysrepo callbacks)
self.copy("candidate", "running")
def patch_config(self, xpath, edit, retries=3):
"""PATCH configuration directly to running datastore
@@ -356,6 +360,7 @@ class Device(Transport):
edit: Configuration dictionary
retries: Number of retry attempts on failure (default 3)
"""
coverage.track_dict(xpath, edit)
try:
mod = self.lyctx.get_module(xpath)
except libyang.util.LibyangError:
@@ -406,6 +411,7 @@ class Device(Transport):
raise last_error
def call_dict(self, model, call):
coverage.track_dict(model, call)
pass # Need implementation
def call_rpc(self, rpc):
@@ -426,6 +432,7 @@ class Device(Transport):
def get_data(self, xpath=None, parse=True):
"""Get operational data"""
coverage.track_xpath(xpath)
uri = xpath_to_uri(xpath) if xpath is not None else None
data = self.get_operational(uri, parse)
@@ -453,6 +460,7 @@ class Device(Transport):
self.call_rpc("ietf-system:system-restart")
def call_action(self, xpath, input_data=None):
coverage.track_xpath(xpath)
path = xpath_to_uri(xpath)
url = f"{self.restconf_url}/data{path}"
@@ -484,6 +492,7 @@ class Device(Transport):
def delete_xpath(self, xpath):
"""Delete XPath from running config"""
coverage.track_xpath(xpath)
path = f"/ds/ietf-datastores:running{xpath_to_uri(xpath)}"
url = f"{self.restconf_url}{path}"
response = requests_workaround_delete(url, headers=self.headers,
+28 -2
View File
@@ -10,6 +10,11 @@ LOGO ?= $(test-dir)/../doc/logo.png[top=40%, align=right, pdfwidth
UNIT_TESTS ?= $(test-dir)/case/all-repo.yaml $(test-dir)/case/all-unit.yaml
TESTS ?= $(test-dir)/case/all.yaml
yang_extractor := $(test-dir)/utils/extract_xpaths.py
coverage_reporter := $(test-dir)/utils/coverage_report.py
YANG_DIR ?= $(BR2_EXTERNAL_INFIX_PATH)/src/confd/yang/confd
xpaths_all_csv := $(test-dir)/.log/xpaths_all.csv
base := -b $(base-dir)
TEST_MODE ?= qeneth
@@ -30,9 +35,15 @@ endif
test:
$(test-dir)/env -r $(base) $(mode) $(binaries) $(pkg-$(ARCH)) \
sh -c '$(ninepm) -v $(TESTS); rc=$$?; \
sh -c 'test -f $(xpaths_all_csv) || python3 $(yang_extractor) $(YANG_DIR) $(xpaths_all_csv) || true; \
$(ninepm) -v $(TESTS); rc=$$?; \
$(ninepm_report) github $(test-dir)/.log/last/result.json; \
$(ninepm_report) asciidoc $(test-dir)/.log/last/result.json; \
python3 $(coverage_reporter) \
$(xpaths_all_csv) \
$(test-dir)/.log/last/xpath_coverage.log \
$(test-dir)/.log/last/xpath_coverage_report.md \
2>/dev/null || true; \
chmod -R 777 $(test-dir)/.log; \
exit $$rc'
@@ -63,9 +74,24 @@ test-report:
--logo "$(LOGO)" \
-o $(test-report)
xpath_coverage_report_md := $(test-dir)/.log/last/xpath_coverage_report.md
xpath_coverage_report_css := $(test-dir)/utils/coverage_report.css
xpath_coverage_report_pdf := $(BINARIES_DIR)/xpath-coverage-report.pdf
xpath_coverage_renderer := $(test-dir)/utils/render_coverage_pdf.sh
xpath_coverage_logo := $(abspath $(test-dir)/../doc/logo.png)
# Rendered inside the infix-test container, which carries pandoc, weasyprint
# and the fonts; make variables are expanded on the host and passed as args.
xpath-coverage-report:
$(test-dir)/env $(base) $(xpath_coverage_renderer) \
$(xpath_coverage_report_md) \
$(xpath_coverage_report_css) \
$(xpath_coverage_logo) \
"$(subst ",,$(INFIX_NAME)) $(INFIX_VERSION)" \
$(xpath_coverage_report_pdf)
# Unit tests run with random (-r) hostname and container name to
# prevent race conditions when running in CI environments.
test-unit:
$(test-dir)/env -r $(base) $(ninepm) -v $(UNIT_TESTS)
.PHONY: test test-sh test-unit test-spec
.PHONY: test test-sh test-unit test-spec xpath-coverage-report
+137
View File
@@ -0,0 +1,137 @@
/*
* Stylesheet for the XPath coverage report PDF (pandoc --pdf-engine=weasyprint).
*
* The Markdown report uses emoji (🟢🟠🔴✅❌) for status. WeasyPrint cannot
* embed the bitmap glyphs of "Noto Color Emoji" into a PDF, so those render as
* empty boxes. The Makefile therefore rewrites the emoji into <span> marks
* (● ✓ ✗) which are plain text glyphs present in every font; the colours below
* restore the green/orange/red meaning.
*/
@page {
size: A4;
margin: 18mm 14mm;
/* Footer: a full-width rule with the page number on it. The number sits
* in the outside corner (alternated below); the border-top draws the line
* above the footer, like the test specification/report. */
@bottom-center {
content: counter(page);
width: 100%;
border-top: 0.75pt solid #999999;
padding-top: 6pt;
font-family: "DejaVu Sans", "Noto Sans", sans-serif;
font-size: 8pt;
color: #777777;
}
}
/* Page number in the outside corner: left on left-hand (even) pages,
* right on right-hand (odd) pages. */
@page :left { @bottom-center { text-align: left; } }
@page :right { @bottom-center { text-align: right; } }
/* The cover page carries no footer (no rule, no number). */
@page cover {
@bottom-center { content: none; }
}
body {
font-family: "DejaVu Sans", "Noto Sans", sans-serif;
font-size: 9pt;
line-height: 1.4;
color: #1b1b1b;
}
h1 { font-size: 22pt; margin: 0 0 4pt; }
/*
* Cover page — logo, title, software version and generation date, on a page of
* its own (page-break-after). Mirrors the asciidoctor-pdf title page used by
* the test specification/report; the logo is doc/logo.png (set by the Makefile).
*
* NOTE: WeasyPrint does not support viewport units (vh/vw), so the vertical
* offset is a fixed length (~40% down an A4 page, like the test report logo).
*/
.cover {
page: cover;
page-break-after: always;
text-align: right;
margin-left: auto;
padding-top: 80mm;
/* Cover is page 0 (number suppressed below), so the first body page is 1. */
counter-reset: page 0;
}
.cover-logo {
width: 90mm;
margin-bottom: 18pt;
}
.cover-title {
color: gray;
font-size: 26pt;
font-weight: 400;
margin: 0 0 8pt;
border: none;
}
.cover-version {
font-size: 13pt;
font-weight: 400;
margin: 0 0 2pt;
}
.cover-date {
font-size: 11pt;
color: #777777;
margin: 0;
}
h2 {
font-size: 15pt;
margin: 18pt 0 6pt;
border-bottom: 1px solid #dddddd;
padding-bottom: 3pt;
}
h3 { font-size: 11pt; margin: 14pt 0 4pt; }
code, pre {
font-family: "DejaVu Sans Mono", "Noto Sans Mono", monospace;
font-size: 8pt;
}
table {
border-collapse: collapse;
width: 100%;
margin: 6pt 0 12pt;
}
th, td {
border: 1px solid #d0d0d0;
padding: 3pt 6pt;
vertical-align: top;
/* Long XPaths have no spaces; allow them to wrap anywhere. */
word-break: break-all;
overflow-wrap: anywhere;
}
thead th {
background: #f2f2f2;
font-weight: 600;
border-bottom: 2px solid #bbbbbb;
}
tbody tr:nth-child(even) { background: #fafafa; }
/* Status dots (module summary + section headings). */
.dot {
display: inline-block;
width: 9pt;
height: 9pt;
border-radius: 50%;
vertical-align: middle;
}
.dot.ok { background: #2e9e44; } /* 🟢 >= 80% */
.dot.warn { background: #f0883e; } /* 🟠 1-79% */
.dot.err { background: #e5534b; } /* 🔴 0% */
/* Per-row covered marks. */
.mark { font-weight: 700; }
.mark.ok { color: #2e9e44; } /* ✅ */
.mark.err { color: #e5534b; } /* ❌ */
+191
View File
@@ -0,0 +1,191 @@
#!/usr/bin/env python3
"""Generate an XPath coverage report after a test run.
Usage:
coverage_report.py <xpaths_all.csv> <xpath_coverage.log> <output.md>
xpaths_all.csv - produced by extract_xpaths.py; columns: module,keyword,xpath
xpath_coverage.log - produced by infamy/coverage.py; one xpath per line
output.md - destination for the markdown report
"""
import csv
import os
import sys
from collections import defaultdict
from datetime import datetime
# Thresholds for colour coding
GREEN_THRESHOLD = 80 # %
ORANGE_THRESHOLD = 1 # % (anything >= 1 and < 80 is orange)
# Modules excluded from the report
SKIP_MODULES: set[str] = {
"infix-if-base", # submodule of infix-interfaces; only a structural choice node
}
def load_all_xpaths(csv_path: str) -> list[dict]:
rows = []
with open(csv_path, newline="", encoding="utf-8") as f:
for row in csv.DictReader(f):
if row["module"] not in SKIP_MODULES:
rows.append(row)
return rows
def load_tracked_xpaths(log_path: str) -> set[str]:
tracked: set[str] = set()
if not os.path.exists(log_path):
return tracked
with open(log_path, encoding="utf-8") as f:
for line in f:
x = line.strip()
if x:
tracked.add(x)
return tracked
def is_covered(yang_xpath: str, tracked: set[str], module: str,
xpath_module: dict[str, str]) -> bool:
""" Return True if yang_xpath was exercised by a test. """
if f"/{module}:*" in tracked:
return True
if yang_xpath in tracked:
return True
prefix = yang_xpath + "/"
for t in tracked:
if not prefix.startswith(t + "/"):
continue
# Determine the defining module of the tracked ancestor
t_module = xpath_module.get(t)
if t_module is None:
# Not in the CSV; infer from the root path segment (e.g. /mod:node → mod)
seg = t.lstrip("/").split("/")[0]
t_module = seg.split(":")[0] if ":" in seg else None
if t_module == module:
return True
return False
def build_coverage(all_xpaths: list[dict], tracked: set[str]) -> dict:
"""Return per-module coverage data.
Returns:
{module: {"total": int, "covered": int, "rows": [{"xpath", "keyword", "covered"}]}}
"""
xpath_module: dict[str, str] = {row["xpath"]: row["module"] for row in all_xpaths}
modules: dict[str, dict] = defaultdict(lambda: {"total": 0, "covered": 0, "rows": []})
for row in all_xpaths:
mod = row["module"]
xpath = row["xpath"]
kw = row["keyword"]
hit = is_covered(xpath, tracked, mod, xpath_module)
modules[mod]["total"] += 1
modules[mod]["covered"] += int(hit)
modules[mod]["rows"].append({"xpath": xpath, "keyword": kw, "covered": hit})
# Sort rows within each module by xpath
for mod_data in modules.values():
mod_data["rows"].sort(key=lambda r: r["xpath"])
return dict(sorted(modules.items()))
def status_emoji(pct: float) -> str:
if pct >= GREEN_THRESHOLD:
return "🟢"
if pct >= ORANGE_THRESHOLD:
return "🟠"
return "🔴"
def pct(covered: int, total: int) -> float:
return 100.0 * covered / total if total else 0.0
def write_markdown(coverage: dict, output_path: str) -> None:
total_all = sum(d["total"] for d in coverage.values())
covered_all = sum(d["covered"] for d in coverage.values())
overall_pct = pct(covered_all, total_all)
lines: list[str] = []
lines.append("# XPath Coverage Report\n")
lines.append(f"Generated: {datetime.now().strftime('%Y-%m-%d')}\n")
lines.append("")
# ── Overall summary ──────────────────────────────────────────────────────
lines.append("## Summary\n")
lines.append(
f"{status_emoji(overall_pct)} **Overall: "
f"{covered_all}/{total_all} XPaths covered "
f"({overall_pct:.1f}%)**\n"
)
lines.append("")
lines.append("| Status | Module | Covered | Total | % |")
lines.append("|--------|--------|--------:|------:|--:|")
for mod, d in sorted(coverage.items()):
p = pct(d["covered"], d["total"])
lines.append(
f"| {status_emoji(p)} | {mod} "
f"| {d['covered']} | {d['total']} | {p:.1f}% |"
)
lines.append("")
# ── Per-module detail ─────────────────────────────────────────────────────
lines.append("## Details\n")
for mod, d in coverage.items():
p = pct(d["covered"], d["total"])
lines.append(
f"### {status_emoji(p)} {mod} "
f"({d['covered']}/{d['total']}{p:.1f}%)\n"
)
lines.append("| XPath | Keyword | Covered |")
lines.append("|-------|---------|:-------:|")
for row in d["rows"]:
tick = "" if row["covered"] else ""
lines.append(f"| `{row['xpath']}` | {row['keyword']} | {tick} |")
lines.append("")
with open(output_path, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
print(f"Coverage report written to {output_path}")
def main() -> None:
if len(sys.argv) < 4:
print(f"Usage: {sys.argv[0]} <xpaths_all.csv> <xpath_coverage.log> <output.md>")
sys.exit(1)
all_csv = sys.argv[1]
cov_log = sys.argv[2]
out_md = sys.argv[3]
if not os.path.exists(all_csv):
print(f"Error: XPath list not found: {all_csv}", file=sys.stderr)
sys.exit(1)
all_xpaths = load_all_xpaths(all_csv)
tracked = load_tracked_xpaths(cov_log)
coverage = build_coverage(all_xpaths, tracked)
write_markdown(coverage, out_md)
total_all = sum(d["total"] for d in coverage.values())
covered_all = sum(d["covered"] for d in coverage.values())
print(
f"Overall: {covered_all}/{total_all} XPaths covered "
f"({pct(covered_all, total_all):.1f}%)"
)
if __name__ == "__main__":
main()
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""Extract all XPaths from YANG modules in a directory.
Outputs a CSV with columns: module, keyword, xpath
XPaths use NETCONF format: /module:top-container/sub/...
"""
import os
import sys
import csv
from pyang import context, repository
def build_xpath(node):
"""Build a NETCONF-style xpath for a schema node."""
parts = []
module_name = None
while node is not None:
if hasattr(node, "arg") and node.arg:
parts.append(node.arg)
parent = getattr(node, "parent", None)
if parent is None or parent.keyword == "module":
if parent is not None:
module_name = parent.arg
break
node = parent
reversed_parts = list(reversed(parts))
if module_name and reversed_parts:
reversed_parts[0] = f"{module_name}:{reversed_parts[0]}"
return "/" + "/".join(reversed_parts)
def collect_xpaths(stmt, results):
schema_keywords = {
"container",
"list",
"leaf",
"leaf-list",
"choice",
"case",
"rpc",
"action",
"notification",
"anyxml",
"anydata",
}
# config false (operational state) is inherited; pyang resolves the
# effective status into i_config during validation. Only skip when it is
# explicitly False so rpc/action/notification (i_config is None) are kept.
if stmt.keyword in schema_keywords and getattr(stmt, "i_config", None) is not False:
results.append({
"module": stmt.i_module.arg,
"keyword": stmt.keyword,
"xpath": build_xpath(stmt),
})
for child in getattr(stmt, "i_children", []):
collect_xpaths(child, results)
def load_yang_modules(yang_dir):
repo = repository.FileRepository(yang_dir)
ctx = context.Context(repo)
for root, _, files in os.walk(yang_dir):
for file in sorted(files):
if file.endswith(".yang") and "@" not in file:
filepath = os.path.join(root, file)
with open(filepath, "r", encoding="utf-8") as f:
text = f.read()
module = ctx.add_module(file, text)
if module is None:
print(f"Warning: failed to load {file}", file=sys.stderr)
ctx.validate()
return ctx
def export_xpaths(yang_dir, output_csv):
ctx = load_yang_modules(yang_dir)
results = []
for module in ctx.modules.values():
for child in getattr(module, "i_children", []):
collect_xpaths(child, results)
out_dir = os.path.dirname(output_csv)
if out_dir:
os.makedirs(out_dir, exist_ok=True)
with open(output_csv, "w", newline="", encoding="utf-8") as csvfile:
writer = csv.DictWriter(csvfile, fieldnames=["module", "keyword", "xpath"])
writer.writeheader()
for row in sorted(results, key=lambda x: (x["module"], x["xpath"])):
writer.writerow(row)
print(f"Extracted {len(results)} XPaths to {output_csv}")
return len(results)
if __name__ == "__main__":
if len(sys.argv) < 3:
print(f"Usage: {sys.argv[0]} <yang_dir> <output_csv>")
sys.exit(1)
export_xpaths(sys.argv[1], sys.argv[2])
+43
View File
@@ -0,0 +1,43 @@
#!/bin/sh
# Render the XPath coverage markdown report to PDF using pandoc + weasyprint.
#
# Prepends an HTML cover page and replaces the emoji status markers with
# CSS-styled spans, then hands the result to pandoc. Runs inside the
# infix-test container, where pandoc, weasyprint and the fonts live.
#
# Usage:
# render_coverage_pdf.sh <report.md> <report.css> <logo.png> <version> <output.pdf>
set -e
md="$1"
css="$2"
logo="$3"
ver="$4"
pdf="$5"
gen=$(sed -n 's/^Generated: //p' "$md" | head -1 | cut -d' ' -f1)
{
printf '<div class="cover">\n'
printf '<img class="cover-logo" src="%s">\n' "$logo"
printf '<h1 class="cover-title">XPath Coverage Report</h1>\n'
printf '<p class="cover-version">%s</p>\n' "$ver"
printf '<p class="cover-date">%s</p>\n' "$gen"
printf '</div>\n\n'
sed -e 's,🟢,<span class="dot ok"></span>,g' \
-e 's,🟠,<span class="dot warn"></span>,g' \
-e 's,🔴,<span class="dot err"></span>,g' \
-e 's,✅,<span class="mark ok">✓</span>,g' \
-e 's,❌,<span class="mark err">✗</span>,g' \
-e '/^# XPath Coverage Report$/d' \
-e '/^Generated: /d' \
"$md"
} > "$md.pdf.md"
pandoc "$md.pdf.md" \
-f gfm \
--pdf-engine=weasyprint \
-c "$css" \
-o "$pdf"
rm -f "$md.pdf.md"