Ryan Malloy bfab802e05 coverage: add cross-process coverage collection for containerized flowgraphs
New MCP tools:
- collect_coverage(name) - combine parallel files, return summary
- generate_coverage_report(name, format) - HTML/XML/JSON reports
- combine_coverage(names) - aggregate across test runs
- delete_coverage(name?, older_than_days?) - cleanup

Modified:
- launch_flowgraph() now accepts enable_coverage parameter
- stop() uses 30s timeout for graceful shutdown (coverage needs atexit)

Docker:
- Dockerfile.gnuradio-coverage extends runtime with python3-coverage
- entrypoint-coverage.sh wraps execution with coverage run
- .coveragerc configured for GNU Radio source paths

Tests: 125 unit tests (21 new), 80% coverage
2026-01-27 13:50:17 -07:00

489 lines
18 KiB
Python

from __future__ import annotations
import logging
import re
import shutil
import subprocess
from pathlib import Path
from typing import Any, Literal
from gnuradio_mcp.middlewares.docker import DockerMiddleware, HOST_COVERAGE_BASE
from gnuradio_mcp.middlewares.xmlrpc import XmlRpcMiddleware
from gnuradio_mcp.models import (
ConnectionInfoModel,
ContainerModel,
CoverageDataModel,
CoverageReportModel,
RuntimeStatusModel,
ScreenshotModel,
VariableModel,
)
logger = logging.getLogger(__name__)
class RuntimeProvider:
"""Business logic for runtime flowgraph control.
Coordinates Docker (container lifecycle) and XML-RPC (variable control).
Tracks the active connection so convenience methods like get_variable()
work without repeating the URL each call.
"""
def __init__(
self,
docker_mw: DockerMiddleware | None = None,
):
self._docker = docker_mw
self._xmlrpc: XmlRpcMiddleware | None = None
self._active_container: str | None = None
@property
def _has_docker(self) -> bool:
return self._docker is not None
def _require_docker(self) -> DockerMiddleware:
if self._docker is None:
raise RuntimeError(
"Docker is not available. Install the 'docker' package "
"and ensure the Docker daemon is running."
)
return self._docker
def _require_xmlrpc(self) -> XmlRpcMiddleware:
if self._xmlrpc is None:
raise RuntimeError(
"Not connected to a flowgraph. Use connect() or "
"connect_to_container() first."
)
return self._xmlrpc
# ──────────────────────────────────────────
# Container Lifecycle
# ──────────────────────────────────────────
def launch_flowgraph(
self,
flowgraph_path: str,
name: str | None = None,
xmlrpc_port: int = 8080,
enable_vnc: bool = False,
enable_coverage: bool = False,
device_paths: list[str] | None = None,
) -> ContainerModel:
"""Launch a flowgraph in a Docker container with Xvfb.
Args:
flowgraph_path: Path to the .py flowgraph file
name: Container name (defaults to 'gr-{stem}')
xmlrpc_port: Port for XML-RPC variable control
enable_vnc: Enable VNC server for visual debugging
enable_coverage: Enable Python code coverage collection
device_paths: Host device paths to pass through
"""
docker = self._require_docker()
if name is None:
name = f"gr-{Path(flowgraph_path).stem}"
return docker.launch(
flowgraph_path=flowgraph_path,
name=name,
xmlrpc_port=xmlrpc_port,
enable_vnc=enable_vnc,
enable_coverage=enable_coverage,
device_paths=device_paths,
)
def list_containers(self) -> list[ContainerModel]:
"""List all gr-mcp managed containers."""
docker = self._require_docker()
return docker.list_containers()
def stop_flowgraph(self, name: str) -> bool:
"""Stop a running flowgraph container."""
docker = self._require_docker()
return docker.stop(name)
def remove_flowgraph(self, name: str, force: bool = False) -> bool:
"""Remove a flowgraph container."""
docker = self._require_docker()
return docker.remove(name, force=force)
# ──────────────────────────────────────────
# Connection Management
# ──────────────────────────────────────────
def connect(self, url: str) -> ConnectionInfoModel:
"""Connect to a GNU Radio XML-RPC endpoint."""
self._xmlrpc = XmlRpcMiddleware.connect(url)
self._active_container = None
# Parse port from URL
from urllib.parse import urlparse
parsed = urlparse(url)
port = parsed.port or 8080
return self._xmlrpc.get_connection_info(xmlrpc_port=port)
def connect_to_container(self, name: str) -> ConnectionInfoModel:
"""Connect to a flowgraph by container name (resolves port automatically)."""
docker = self._require_docker()
port = docker.get_xmlrpc_port(name)
url = f"http://localhost:{port}"
self._xmlrpc = XmlRpcMiddleware.connect(url)
self._active_container = name
return self._xmlrpc.get_connection_info(
container_name=name, xmlrpc_port=port
)
def disconnect(self) -> bool:
"""Disconnect from the current XML-RPC endpoint."""
if self._xmlrpc is not None:
self._xmlrpc.close()
self._xmlrpc = None
self._active_container = None
return True
def get_status(self) -> RuntimeStatusModel:
"""Get runtime status including connection and container info."""
connection = None
if self._xmlrpc is not None:
from urllib.parse import urlparse
parsed = urlparse(self._xmlrpc._url)
port = parsed.port or 8080
connection = self._xmlrpc.get_connection_info(
container_name=self._active_container, xmlrpc_port=port
)
containers = []
if self._has_docker:
try:
containers = self._docker.list_containers() # type: ignore[union-attr]
except Exception as e:
logger.warning("Failed to list containers: %s", e)
return RuntimeStatusModel(
connected=self._xmlrpc is not None,
connection=connection,
containers=containers,
)
# ──────────────────────────────────────────
# Variable Control
# ──────────────────────────────────────────
def list_variables(self) -> list[VariableModel]:
"""List all XML-RPC-exposed variables."""
xmlrpc = self._require_xmlrpc()
return xmlrpc.list_variables()
def get_variable(self, name: str) -> Any:
"""Get a variable value."""
xmlrpc = self._require_xmlrpc()
return xmlrpc.get_variable(name)
def set_variable(self, name: str, value: Any) -> bool:
"""Set a variable value."""
xmlrpc = self._require_xmlrpc()
return xmlrpc.set_variable(name, value)
# ──────────────────────────────────────────
# Flowgraph Execution Control
# ──────────────────────────────────────────
def start(self) -> bool:
"""Start the connected flowgraph."""
return self._require_xmlrpc().start()
def stop(self) -> bool:
"""Stop the connected flowgraph."""
return self._require_xmlrpc().stop()
def lock(self) -> bool:
"""Lock the flowgraph for thread-safe parameter updates."""
return self._require_xmlrpc().lock()
def unlock(self) -> bool:
"""Unlock the flowgraph after parameter updates."""
return self._require_xmlrpc().unlock()
# ──────────────────────────────────────────
# Visual Feedback
# ──────────────────────────────────────────
def capture_screenshot(self, name: str | None = None) -> ScreenshotModel:
"""Capture a screenshot of the flowgraph's QT GUI."""
docker = self._require_docker()
container_name = name or self._active_container
if container_name is None:
raise RuntimeError(
"No container specified. Provide a name or connect to a container first."
)
return docker.capture_screenshot(container_name)
def get_container_logs(self, name: str | None = None, tail: int = 100) -> str:
"""Get logs from a flowgraph container."""
docker = self._require_docker()
container_name = name or self._active_container
if container_name is None:
raise RuntimeError(
"No container specified. Provide a name or connect to a container first."
)
return docker.get_logs(container_name, tail=tail)
# ──────────────────────────────────────────
# Coverage Collection
# ──────────────────────────────────────────
def _get_coverage_dir(self, name: str) -> Path:
"""Get coverage directory for a container, ensure it exists."""
coverage_dir = Path(HOST_COVERAGE_BASE) / name
if not coverage_dir.exists():
raise FileNotFoundError(
f"No coverage data for container '{name}'. "
f"Was it launched with enable_coverage=True?"
)
return coverage_dir
def _parse_coverage_summary(self, output: str) -> dict[str, int | float | None]:
"""Parse coverage report output for metrics.
Example output:
Name Stmts Miss Branch BrPart Cover
---------------------------------------------------------
gnuradio/__init__.py 10 2 4 1 75%
...
TOTAL 100 25 40 10 70%
"""
result: dict[str, int | float | None] = {
"lines_covered": None,
"lines_total": None,
"coverage_percent": None,
}
# Look for TOTAL line
match = re.search(
r"^TOTAL\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)%",
output,
re.MULTILINE,
)
if match:
total_stmts = int(match.group(1))
miss_stmts = int(match.group(2))
result["lines_total"] = total_stmts
result["lines_covered"] = total_stmts - miss_stmts
result["coverage_percent"] = float(match.group(5))
return result
def collect_coverage(self, name: str) -> CoverageDataModel:
"""Collect coverage data from a stopped container.
Combines any parallel coverage files and returns a summary.
Container must have been stopped (not removed) for coverage
data to be available.
Args:
name: Container name
"""
coverage_dir = self._get_coverage_dir(name)
# First, combine any parallel files (idempotent if already combined)
# This handles both single-run and multi-run scenarios
subprocess.run(
["coverage", "combine"],
cwd=coverage_dir,
capture_output=True,
)
coverage_file = coverage_dir / ".coverage"
if not coverage_file.exists():
# Check for parallel files that weren't combined
parallel_files = list(coverage_dir.glob(".coverage.*"))
if parallel_files:
raise RuntimeError(
f"Coverage combine failed. Found {len(parallel_files)} "
f"parallel files but no combined .coverage file."
)
raise FileNotFoundError(
f"No coverage data found in {coverage_dir}. "
f"Container may not have generated coverage data."
)
# Generate summary report
result = subprocess.run(
["coverage", "report", "--data-file", str(coverage_file)],
capture_output=True,
text=True,
)
summary = result.stdout if result.returncode == 0 else result.stderr
metrics = self._parse_coverage_summary(summary)
return CoverageDataModel(
container_name=name,
coverage_file=str(coverage_file),
summary=summary,
lines_covered=metrics["lines_covered"],
lines_total=metrics["lines_total"],
coverage_percent=metrics["coverage_percent"],
)
def generate_coverage_report(
self,
name: str,
format: Literal["html", "xml", "json"] = "html",
) -> CoverageReportModel:
"""Generate a coverage report in the specified format.
Args:
name: Container name
format: Report format (html, xml, json)
"""
coverage_dir = self._get_coverage_dir(name)
coverage_file = coverage_dir / ".coverage"
if not coverage_file.exists():
raise FileNotFoundError(
f"No combined coverage file for '{name}'. "
f"Call collect_coverage() first."
)
if format == "html":
report_path = coverage_dir / "htmlcov" / "index.html"
subprocess.run(
[
"coverage", "html",
"--data-file", str(coverage_file),
"-d", str(coverage_dir / "htmlcov"),
],
capture_output=True,
check=True,
)
elif format == "xml":
report_path = coverage_dir / "coverage.xml"
subprocess.run(
[
"coverage", "xml",
"--data-file", str(coverage_file),
"-o", str(report_path),
],
capture_output=True,
check=True,
)
elif format == "json":
report_path = coverage_dir / "coverage.json"
subprocess.run(
[
"coverage", "json",
"--data-file", str(coverage_file),
"-o", str(report_path),
],
capture_output=True,
check=True,
)
else:
raise ValueError(f"Unsupported format: {format}")
return CoverageReportModel(
container_name=name,
format=format,
report_path=str(report_path),
)
def combine_coverage(self, names: list[str]) -> CoverageDataModel:
"""Combine coverage data from multiple containers.
Useful for aggregating coverage across a test suite.
Args:
names: List of container names to combine
"""
if not names:
raise ValueError("At least one container name required")
combined_dir = Path(HOST_COVERAGE_BASE) / "combined"
combined_dir.mkdir(parents=True, exist_ok=True)
# Clear any existing combined data
for f in combined_dir.glob(".coverage*"):
f.unlink()
# Copy all coverage files to combined directory
for name in names:
coverage_dir = self._get_coverage_dir(name)
for cov_file in coverage_dir.glob(".coverage*"):
# Ensure unique names when copying
dest_name = f".coverage.{name}.{cov_file.name}"
shutil.copy(cov_file, combined_dir / dest_name)
# Run coverage combine
subprocess.run(
["coverage", "combine"],
cwd=combined_dir,
capture_output=True,
check=True,
)
# Generate summary
coverage_file = combined_dir / ".coverage"
result = subprocess.run(
["coverage", "report", "--data-file", str(coverage_file)],
capture_output=True,
text=True,
)
summary = result.stdout if result.returncode == 0 else result.stderr
metrics = self._parse_coverage_summary(summary)
return CoverageDataModel(
container_name="combined",
coverage_file=str(coverage_file),
summary=summary,
lines_covered=metrics["lines_covered"],
lines_total=metrics["lines_total"],
coverage_percent=metrics["coverage_percent"],
)
def delete_coverage(
self,
name: str | None = None,
older_than_days: int | None = None,
) -> int:
"""Delete coverage data.
Args:
name: Delete specific container's coverage
older_than_days: Delete all coverage older than N days
Returns:
Number of coverage directories deleted
"""
import time
deleted = 0
coverage_base = Path(HOST_COVERAGE_BASE)
if not coverage_base.exists():
return 0
if name is not None:
# Delete specific container's coverage
coverage_dir = coverage_base / name
if coverage_dir.exists():
shutil.rmtree(coverage_dir)
deleted += 1
elif older_than_days is not None:
# Delete coverage older than N days
cutoff = time.time() - (older_than_days * 86400)
for coverage_dir in coverage_base.iterdir():
if coverage_dir.is_dir():
if coverage_dir.stat().st_mtime < cutoff:
shutil.rmtree(coverage_dir)
deleted += 1
else:
# No filter - delete all
for coverage_dir in coverage_base.iterdir():
if coverage_dir.is_dir():
shutil.rmtree(coverage_dir)
deleted += 1
return deleted