mirror of
https://github.com/kernelkit/infix.git
synced 2026-07-22 01:13:00 +02:00
test: Added xpath coverage report generation (make test)
This commit introduces xpath coverage test report. It plugs into transport layer of test system and generates .md report. Commit also introduces "make xpath-coverage-report" used to generate pdf out of md. Resolves: #581 Signed-off-by: Ejub Sabic <ejub1946@outlook.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
libyang==2.7.1
|
||||
pyang==2.7.1
|
||||
netconf_client==3.1.1
|
||||
netifaces==0.11.0
|
||||
networkx==3.1
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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'
|
||||
|
||||
@@ -62,9 +73,24 @@ test-report:
|
||||
-a pdf-fontsdir=$(spec-dir)/fonts \
|
||||
-o $(test-report) $(test-dir)/.log/last/report.adoc
|
||||
|
||||
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
|
||||
|
||||
@@ -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; } /* ❌ */
|
||||
@@ -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()
|
||||
@@ -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])
|
||||
Executable
+43
@@ -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"
|
||||
Reference in New Issue
Block a user