Compare commits
10 Commits
521c306173
...
e63f6e1ba0
| Author | SHA1 | Date | |
|---|---|---|---|
| e63f6e1ba0 | |||
| 87781670f7 | |||
| 652c4796a9 | |||
| 3811099623 | |||
| bf92c70d3b | |||
| 15c17aa0a0 | |||
| 66f1b260f2 | |||
| ca114fe2cb | |||
| 30a1fecc6b | |||
| 2956ceab0f |
@ -4,11 +4,20 @@ import io
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
import tarfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from gnuradio_mcp.models import OOTImageInfo, OOTInstallResult
|
||||
import re
|
||||
|
||||
from gnuradio_mcp.models import (
|
||||
ComboImageInfo,
|
||||
ComboImageResult,
|
||||
OOTDetectionResult,
|
||||
OOTImageInfo,
|
||||
OOTInstallResult,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -25,8 +34,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \\
|
||||
|
||||
# Clone and build
|
||||
WORKDIR /build
|
||||
COPY fix_binding_hashes.py /tmp/fix_binding_hashes.py
|
||||
RUN git clone --depth 1 --branch {branch} {git_url} && \\
|
||||
cd {repo_dir} && mkdir build && cd build && \\
|
||||
cd {repo_dir} && \\
|
||||
python3 /tmp/fix_binding_hashes.py . && \\
|
||||
mkdir build && cd build && \\
|
||||
cmake -DCMAKE_INSTALL_PREFIX=/usr {cmake_args}.. && \\
|
||||
make -j$(nproc) && make install && \\
|
||||
ldconfig && \\
|
||||
@ -38,6 +50,47 @@ WORKDIR /flowgraphs
|
||||
ENV PYTHONPATH="/usr/lib/python3.11/site-packages:${{PYTHONPATH}}"
|
||||
"""
|
||||
|
||||
# Standalone script injected into OOT Docker builds to fix stale
|
||||
# pybind11 binding hashes that would otherwise trigger castxml regen.
|
||||
FIX_BINDING_HASHES_SCRIPT = """\
|
||||
#!/usr/bin/env python3
|
||||
\"\"\"Fix stale BINDTOOL_HEADER_FILE_HASH in pybind11 binding files.
|
||||
|
||||
GNU Radio's GR_PYBIND_MAKE_OOT cmake macro compares MD5 hashes of C++
|
||||
headers against values stored in the binding .cc files. When they
|
||||
differ it tries to regenerate via castxml, which often fails in minimal
|
||||
Docker images. This script updates the hashes to match the actual
|
||||
headers so cmake skips the regeneration step.
|
||||
\"\"\"
|
||||
import hashlib, pathlib, re, sys
|
||||
|
||||
root = pathlib.Path(sys.argv[1]) if len(sys.argv) > 1 else pathlib.Path(".")
|
||||
|
||||
# GR 3.9-: python/bindings/ | GR 3.10+: python/<module>/bindings/
|
||||
binding_dirs = list(root.joinpath("python").glob("**/bindings"))
|
||||
if not binding_dirs:
|
||||
sys.exit(0)
|
||||
|
||||
for bindings in binding_dirs:
|
||||
for cc in sorted(bindings.glob("*_python.cc")):
|
||||
text = cc.read_text()
|
||||
m = re.search(r"BINDTOOL_HEADER_FILE\\((\\S+)\\)", text)
|
||||
if not m:
|
||||
continue
|
||||
header = next(root.joinpath("include").rglob(m.group(1)), None)
|
||||
if not header:
|
||||
continue
|
||||
actual = hashlib.md5(header.read_bytes()).hexdigest()
|
||||
new_text = re.sub(
|
||||
r"BINDTOOL_HEADER_FILE_HASH\\([a-f0-9]+\\)",
|
||||
f"BINDTOOL_HEADER_FILE_HASH({actual})",
|
||||
text,
|
||||
)
|
||||
if new_text != text:
|
||||
cc.write_text(new_text)
|
||||
print(f"Fixed binding hash: {cc.name}")
|
||||
"""
|
||||
|
||||
|
||||
class OOTInstallerMiddleware:
|
||||
"""Builds OOT modules into Docker images from git repos.
|
||||
@ -56,6 +109,8 @@ class OOTInstallerMiddleware:
|
||||
self._base_image = base_image
|
||||
self._registry_path = Path.home() / ".gr-mcp" / "oot-registry.json"
|
||||
self._registry: dict[str, OOTImageInfo] = self._load_registry()
|
||||
self._combo_registry_path = Path.home() / ".gr-mcp" / "oot-combo-registry.json"
|
||||
self._combo_registry: dict[str, ComboImageInfo] = self._load_combo_registry()
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# Public API
|
||||
@ -81,6 +136,23 @@ class OOTInstallerMiddleware:
|
||||
# Idempotent: skip if image already exists
|
||||
if not force and self._image_exists(image_tag):
|
||||
existing = self._registry.get(module_name)
|
||||
if existing is None:
|
||||
# Image exists but registry entry missing — re-register
|
||||
existing = OOTImageInfo(
|
||||
module_name=module_name,
|
||||
image_tag=image_tag,
|
||||
git_url=git_url,
|
||||
branch=branch,
|
||||
git_commit=commit,
|
||||
base_image=effective_base,
|
||||
built_at=datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
self._registry[module_name] = existing
|
||||
self._save_registry()
|
||||
logger.info(
|
||||
"Re-registered existing image '%s' for module '%s'",
|
||||
image_tag, module_name,
|
||||
)
|
||||
return OOTInstallResult(
|
||||
success=True,
|
||||
image=existing,
|
||||
@ -235,13 +307,30 @@ class OOTInstallerMiddleware:
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _build_context(dockerfile: str) -> io.BytesIO:
|
||||
"""Create a tar archive build context with Dockerfile and helper scripts."""
|
||||
buf = io.BytesIO()
|
||||
with tarfile.open(fileobj=buf, mode="w") as tar:
|
||||
for name, content in [
|
||||
("Dockerfile", dockerfile),
|
||||
("fix_binding_hashes.py", FIX_BINDING_HASHES_SCRIPT),
|
||||
]:
|
||||
data = content.encode("utf-8")
|
||||
info = tarfile.TarInfo(name=name)
|
||||
info.size = len(data)
|
||||
tar.addfile(info, io.BytesIO(data))
|
||||
buf.seek(0)
|
||||
return buf
|
||||
|
||||
def _docker_build(self, dockerfile: str, tag: str) -> list[str]:
|
||||
"""Build a Docker image from a Dockerfile string. Returns log lines."""
|
||||
f = io.BytesIO(dockerfile.encode("utf-8"))
|
||||
context = self._build_context(dockerfile)
|
||||
log_lines: list[str] = []
|
||||
try:
|
||||
_image, build_log = self._client.images.build(
|
||||
fileobj=f,
|
||||
fileobj=context,
|
||||
custom_context=True,
|
||||
tag=tag,
|
||||
rm=True,
|
||||
forcerm=True,
|
||||
@ -257,18 +346,25 @@ class OOTInstallerMiddleware:
|
||||
return log_lines
|
||||
|
||||
def _load_registry(self) -> dict[str, OOTImageInfo]:
|
||||
"""Load the OOT image registry from disk."""
|
||||
"""Load the OOT image registry from disk.
|
||||
|
||||
Validates entries individually so one corrupted entry
|
||||
doesn't discard the entire registry.
|
||||
"""
|
||||
if not self._registry_path.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(self._registry_path.read_text())
|
||||
return {
|
||||
k: OOTImageInfo(**v)
|
||||
for k, v in data.items()
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load OOT registry: %s", e)
|
||||
logger.warning("Failed to parse OOT registry JSON: %s", e)
|
||||
return {}
|
||||
registry: dict[str, OOTImageInfo] = {}
|
||||
for k, v in data.items():
|
||||
try:
|
||||
registry[k] = OOTImageInfo(**v)
|
||||
except Exception as e:
|
||||
logger.warning("Skipping corrupt registry entry '%s': %s", k, e)
|
||||
return registry
|
||||
|
||||
def _save_registry(self) -> None:
|
||||
"""Persist the OOT image registry to disk."""
|
||||
@ -278,3 +374,389 @@ class OOTInstallerMiddleware:
|
||||
for k, v in self._registry.items()
|
||||
}
|
||||
self._registry_path.write_text(json.dumps(data, indent=2))
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# Combo Image (Multi-OOT) Support
|
||||
# ──────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _combo_key(module_names: list[str]) -> str:
|
||||
"""Deterministic key from module names: sorted, deduped, joined."""
|
||||
names = sorted(set(module_names))
|
||||
return "combo:" + "+".join(names)
|
||||
|
||||
@staticmethod
|
||||
def _combo_image_tag(module_names: list[str]) -> str:
|
||||
"""Deterministic image tag for a combo of modules."""
|
||||
names = sorted(set(module_names))
|
||||
return f"gr-combo-{'-'.join(names)}:latest"
|
||||
|
||||
def generate_combo_dockerfile(self, module_names: list[str]) -> str:
|
||||
"""Generate multi-stage Dockerfile that COPYs from existing single-OOT images."""
|
||||
names = sorted(set(module_names))
|
||||
stages: list[str] = []
|
||||
copies: list[str] = []
|
||||
|
||||
for name in names:
|
||||
info = self._registry.get(name)
|
||||
if info is None:
|
||||
raise ValueError(
|
||||
f"Module '{name}' not found in OOT registry. "
|
||||
f"Build it first with build_module()."
|
||||
)
|
||||
stage_alias = f"stage_{name}"
|
||||
stages.append(f"FROM {info.image_tag} AS {stage_alias}")
|
||||
for path in ["/usr/lib/", "/usr/include/", "/usr/share/gnuradio/"]:
|
||||
copies.append(f"COPY --from={stage_alias} {path} {path}")
|
||||
|
||||
return "\n".join([
|
||||
*stages,
|
||||
"",
|
||||
f"FROM {self._base_image}",
|
||||
"",
|
||||
*copies,
|
||||
"",
|
||||
"RUN ldconfig",
|
||||
"WORKDIR /flowgraphs",
|
||||
'ENV PYTHONPATH="/usr/lib/python3.11/site-packages:${PYTHONPATH}"',
|
||||
"",
|
||||
])
|
||||
|
||||
def build_combo_image(
|
||||
self,
|
||||
module_names: list[str],
|
||||
force: bool = False,
|
||||
) -> ComboImageResult:
|
||||
"""Build a combined Docker image with multiple OOT modules.
|
||||
|
||||
Modules already in the registry are used as-is. Modules found
|
||||
in the OOT catalog but not yet built are auto-built first.
|
||||
"""
|
||||
from gnuradio_mcp.oot_catalog import CATALOG
|
||||
|
||||
names = sorted(set(module_names))
|
||||
if len(names) < 2:
|
||||
return ComboImageResult(
|
||||
success=False,
|
||||
error="At least 2 distinct modules required for a combo image.",
|
||||
)
|
||||
|
||||
combo_key = self._combo_key(names)
|
||||
image_tag = self._combo_image_tag(names)
|
||||
|
||||
try:
|
||||
# Idempotent: skip if combo already exists
|
||||
if not force and self._image_exists(image_tag):
|
||||
existing = self._combo_registry.get(combo_key)
|
||||
if existing is not None:
|
||||
return ComboImageResult(
|
||||
success=True,
|
||||
image=existing,
|
||||
skipped=True,
|
||||
)
|
||||
|
||||
# Auto-build missing modules from catalog
|
||||
modules_built: list[str] = []
|
||||
for name in names:
|
||||
if name in self._registry:
|
||||
continue
|
||||
entry = CATALOG.get(name)
|
||||
if entry is None:
|
||||
return ComboImageResult(
|
||||
success=False,
|
||||
error=(
|
||||
f"Module '{name}' is not in the OOT registry and "
|
||||
f"not found in the catalog. Build it manually first "
|
||||
f"with build_module()."
|
||||
),
|
||||
)
|
||||
# Auto-build from catalog
|
||||
logger.info("Auto-building '%s' from catalog for combo image", name)
|
||||
result = self.build_module(
|
||||
git_url=entry.git_url,
|
||||
branch=entry.branch,
|
||||
build_deps=entry.build_deps or None,
|
||||
cmake_args=entry.cmake_args or None,
|
||||
)
|
||||
if not result.success:
|
||||
return ComboImageResult(
|
||||
success=False,
|
||||
error=f"Auto-build of '{name}' failed: {result.error}",
|
||||
modules_built=modules_built,
|
||||
)
|
||||
modules_built.append(name)
|
||||
|
||||
# Generate and build combo
|
||||
dockerfile = self.generate_combo_dockerfile(names)
|
||||
log_lines = self._docker_build(dockerfile, image_tag)
|
||||
build_log_tail = "\n".join(log_lines[-30:])
|
||||
|
||||
# Collect module infos for the combo record
|
||||
module_infos = [self._registry[n] for n in names]
|
||||
|
||||
info = ComboImageInfo(
|
||||
combo_key=combo_key,
|
||||
image_tag=image_tag,
|
||||
modules=module_infos,
|
||||
built_at=datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
self._combo_registry[combo_key] = info
|
||||
self._save_combo_registry()
|
||||
|
||||
return ComboImageResult(
|
||||
success=True,
|
||||
image=info,
|
||||
build_log_tail=build_log_tail,
|
||||
modules_built=modules_built,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Combo image build failed")
|
||||
return ComboImageResult(
|
||||
success=False,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
def list_combo_images(self) -> list[ComboImageInfo]:
|
||||
"""List all combined multi-OOT images."""
|
||||
return list(self._combo_registry.values())
|
||||
|
||||
def remove_combo_image(self, combo_key: str) -> bool:
|
||||
"""Remove a combo image by its key (e.g., 'combo:adsb+lora_sdr')."""
|
||||
info = self._combo_registry.pop(combo_key, None)
|
||||
if info is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
self._client.images.remove(info.image_tag, force=True)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to remove combo Docker image %s: %s", info.image_tag, e
|
||||
)
|
||||
|
||||
self._save_combo_registry()
|
||||
return True
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# Combo Registry Persistence
|
||||
# ──────────────────────────────────────────
|
||||
|
||||
def _load_combo_registry(self) -> dict[str, ComboImageInfo]:
|
||||
"""Load the combo image registry from disk."""
|
||||
if not self._combo_registry_path.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(self._combo_registry_path.read_text())
|
||||
return {k: ComboImageInfo(**v) for k, v in data.items()}
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load combo registry: %s", e)
|
||||
return {}
|
||||
|
||||
def _save_combo_registry(self) -> None:
|
||||
"""Persist the combo image registry to disk."""
|
||||
self._combo_registry_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
data = {k: v.model_dump() for k, v in self._combo_registry.items()}
|
||||
self._combo_registry_path.write_text(json.dumps(data, indent=2))
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# OOT Module Detection
|
||||
# ──────────────────────────────────────────
|
||||
|
||||
# Core GNU Radio module prefixes to ignore during detection
|
||||
_CORE_BLOCK_PREFIXES = frozenset([
|
||||
"blocks_", "analog_", "digital_", "filter_", "qtgui_", "fft_",
|
||||
"audio_", "channels_", "trellis_", "vocoder_", "video_sdl_",
|
||||
"dtv_", "fec_", "network_", "pdu_", "soapy_", "uhd_", "zeromq_",
|
||||
"variable_", # All variable_* blocks (qtgui controls, function probe, etc.)
|
||||
])
|
||||
_CORE_BLOCK_EXACT = frozenset([
|
||||
# Special blocks without prefixes
|
||||
"variable", # The basic variable block (no underscore)
|
||||
"import", "snippet", "options", "parameter",
|
||||
"pad_source", "pad_sink", "virtual_source", "virtual_sink",
|
||||
"note", "epy_block", "epy_module",
|
||||
# Core filter blocks (not prefixed with filter_)
|
||||
"low_pass_filter", "high_pass_filter", "band_pass_filter",
|
||||
"band_reject_filter", "root_raised_cosine_filter",
|
||||
# XML-RPC (part of core GR)
|
||||
"xmlrpc_server", "xmlrpc_client",
|
||||
])
|
||||
|
||||
# Explicit block ID → module mappings for blocks that don't follow
|
||||
# the standard `module_prefix_` naming convention
|
||||
_BLOCK_TO_MODULE: dict[str, str] = {
|
||||
# gr-lora_sdr: main blocks use short names
|
||||
"lora_rx": "lora_sdr",
|
||||
"lora_tx": "lora_sdr",
|
||||
# gr-adsb: decoder blocks
|
||||
"adsb_decoder": "adsb",
|
||||
"adsb_framer": "adsb",
|
||||
# gr-iridium: main blocks
|
||||
"iridium_extractor": "iridium",
|
||||
"iridium_frame_sorter": "iridium",
|
||||
"iridium_qpsk_demod": "iridium",
|
||||
# gr-rds: decoder/encoder
|
||||
"rds_decoder": "rds",
|
||||
"rds_encoder": "rds",
|
||||
"rds_parser": "rds",
|
||||
"rds_panel": "rds",
|
||||
# gr-satellites: common blocks
|
||||
"satellites_ax25_deframer": "satellites",
|
||||
"satellites_hdlc_deframer": "satellites",
|
||||
"satellites_nrzi_decode": "satellites",
|
||||
# gr-gsm: receiver blocks
|
||||
"gsm_receiver": "gsm",
|
||||
"gsm_input": "gsm",
|
||||
"gsm_clock_offset_control": "gsm",
|
||||
"gsm_controlled_rotator_cc": "gsm",
|
||||
}
|
||||
|
||||
def detect_required_modules(self, flowgraph_path: str) -> OOTDetectionResult:
|
||||
"""Detect OOT modules required by a flowgraph.
|
||||
|
||||
For .py files: parse Python imports
|
||||
For .grc files: heuristic prefix matching against catalog
|
||||
|
||||
Args:
|
||||
flowgraph_path: Path to a .py or .grc flowgraph file
|
||||
|
||||
Returns:
|
||||
OOTDetectionResult with detected modules and recommended image
|
||||
"""
|
||||
path = Path(flowgraph_path)
|
||||
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Flowgraph not found: {flowgraph_path}")
|
||||
|
||||
if path.suffix == ".py":
|
||||
return self._detect_from_python(path)
|
||||
elif path.suffix == ".grc":
|
||||
return self._detect_from_grc(path)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unsupported file type: {path.suffix}. "
|
||||
f"Expected .py or .grc"
|
||||
)
|
||||
|
||||
def _detect_from_python(self, path: Path) -> OOTDetectionResult:
|
||||
"""Parse Python imports to find OOT modules."""
|
||||
from gnuradio_mcp.oot_catalog import CATALOG
|
||||
|
||||
content = path.read_text()
|
||||
modules: set[str] = set()
|
||||
|
||||
# Pattern: import gnuradio.MODULE or from gnuradio import MODULE
|
||||
for match in re.finditer(
|
||||
r"(?:import gnuradio\.(\w+)|from gnuradio import (\w+))", content
|
||||
):
|
||||
module = match.group(1) or match.group(2)
|
||||
if module in CATALOG:
|
||||
modules.add(module)
|
||||
|
||||
# Pattern: import MODULE (top-level OOT like osmosdr)
|
||||
for match in re.finditer(r"^import (\w+)\s*$", content, re.MULTILINE):
|
||||
module = match.group(1)
|
||||
if module in CATALOG:
|
||||
modules.add(module)
|
||||
|
||||
# Pattern: from MODULE import ... (top-level OOT)
|
||||
for match in re.finditer(r"^from (\w+) import", content, re.MULTILINE):
|
||||
module = match.group(1)
|
||||
if module in CATALOG:
|
||||
modules.add(module)
|
||||
|
||||
sorted_modules = sorted(modules)
|
||||
return OOTDetectionResult(
|
||||
flowgraph_path=str(path),
|
||||
detected_modules=sorted_modules,
|
||||
detection_method="python_imports",
|
||||
recommended_image=self._recommend_image(sorted_modules),
|
||||
)
|
||||
|
||||
def _detect_from_grc(self, path: Path) -> OOTDetectionResult:
|
||||
"""Heuristic: match block IDs against catalog module prefixes."""
|
||||
import yaml
|
||||
|
||||
from gnuradio_mcp.oot_catalog import CATALOG
|
||||
|
||||
data = yaml.safe_load(path.read_text())
|
||||
blocks = data.get("blocks", [])
|
||||
block_ids = [b.get("id", "") for b in blocks if isinstance(b, dict)]
|
||||
|
||||
modules: set[str] = set()
|
||||
unknown: list[str] = []
|
||||
|
||||
for block_id in block_ids:
|
||||
# Skip empty IDs
|
||||
if not block_id:
|
||||
continue
|
||||
|
||||
# Skip core GNU Radio blocks
|
||||
if block_id in self._CORE_BLOCK_EXACT:
|
||||
continue
|
||||
if any(block_id.startswith(prefix) for prefix in self._CORE_BLOCK_PREFIXES):
|
||||
continue
|
||||
|
||||
# Phase 1: Check explicit block-to-module mapping (handles edge cases)
|
||||
if block_id in self._BLOCK_TO_MODULE:
|
||||
module = self._BLOCK_TO_MODULE[block_id]
|
||||
if module in CATALOG:
|
||||
modules.add(module)
|
||||
continue
|
||||
|
||||
# Phase 2: Match against catalog module names as prefixes
|
||||
matched = False
|
||||
for module_name in CATALOG:
|
||||
# Check for prefix match (e.g., "lora_sdr_gray_demap" -> "lora_sdr")
|
||||
# Also handle cases like "osmosdr_source" -> "osmosdr"
|
||||
if block_id.startswith(f"{module_name}_") or block_id == module_name:
|
||||
modules.add(module_name)
|
||||
matched = True
|
||||
break
|
||||
|
||||
if not matched and "_" in block_id:
|
||||
# Looks like an OOT block but not in catalog
|
||||
# Don't flag all unknown blocks, just those with OOT-like patterns
|
||||
prefix = block_id.split("_")[0]
|
||||
# Check it's not a known core prefix that might have been missed
|
||||
if not any(p.startswith(prefix) for p in self._CORE_BLOCK_PREFIXES):
|
||||
unknown.append(block_id)
|
||||
|
||||
sorted_modules = sorted(modules)
|
||||
return OOTDetectionResult(
|
||||
flowgraph_path=str(path),
|
||||
detected_modules=sorted_modules,
|
||||
unknown_blocks=unknown,
|
||||
detection_method="grc_prefix_heuristic",
|
||||
recommended_image=self._recommend_image(sorted_modules),
|
||||
)
|
||||
|
||||
def _recommend_image(self, modules: list[str]) -> str | None:
|
||||
"""Recommend Docker image for detected modules.
|
||||
|
||||
Args:
|
||||
modules: Sorted list of module names
|
||||
|
||||
Returns:
|
||||
- Base runtime image if no OOT modules
|
||||
- Single OOT image tag if one module (and built)
|
||||
- Combo image tag if multiple modules
|
||||
"""
|
||||
if not modules:
|
||||
return self._base_image
|
||||
|
||||
if len(modules) == 1:
|
||||
# Single module - check if already built
|
||||
info = self._registry.get(modules[0])
|
||||
if info:
|
||||
return info.image_tag
|
||||
# Not built yet - return what the tag would be
|
||||
return None
|
||||
|
||||
# Multiple modules - return combo tag (may or may not exist yet)
|
||||
combo_key = self._combo_key(modules)
|
||||
combo = self._combo_registry.get(combo_key)
|
||||
if combo:
|
||||
return combo.image_tag
|
||||
# Return what the tag would be
|
||||
return self._combo_image_tag(modules)
|
||||
|
||||
@ -391,3 +391,37 @@ class OOTInstallResult(BaseModel):
|
||||
build_log_tail: str = "" # Last ~30 lines of build output
|
||||
error: str | None = None
|
||||
skipped: bool = False # True if image already existed
|
||||
|
||||
|
||||
class ComboImageInfo(BaseModel):
|
||||
"""Metadata for a combined multi-OOT image."""
|
||||
|
||||
combo_key: str # "combo:adsb+lora_sdr" (sorted, deduped)
|
||||
image_tag: str # "gr-combo-adsb-lora_sdr:latest"
|
||||
modules: list[OOTImageInfo] # source images used
|
||||
built_at: str # ISO-8601
|
||||
|
||||
|
||||
class ComboImageResult(BaseModel):
|
||||
"""Result of combo image build."""
|
||||
|
||||
success: bool
|
||||
image: ComboImageInfo | None = None
|
||||
build_log_tail: str = ""
|
||||
error: str | None = None
|
||||
skipped: bool = False # True if combo image already existed
|
||||
modules_built: list[str] = [] # modules auto-built from catalog first
|
||||
|
||||
|
||||
class OOTDetectionResult(BaseModel):
|
||||
"""Result of OOT module detection from a flowgraph.
|
||||
|
||||
Analyzes .py or .grc files to identify which OOT modules are required,
|
||||
enabling automatic Docker image selection for launch_flowgraph().
|
||||
"""
|
||||
|
||||
flowgraph_path: str
|
||||
detected_modules: list[str] # OOT modules found (catalog matches)
|
||||
unknown_blocks: list[str] = [] # Blocks that look OOT but aren't in catalog
|
||||
detection_method: str # "python_imports" | "grc_prefix_heuristic"
|
||||
recommended_image: str | None = None # Image tag to use (if modules found)
|
||||
|
||||
@ -139,7 +139,7 @@ CATALOG: dict[str, OOTModuleEntry] = {
|
||||
description="FM RDS/RBDS (Radio Data System) decoder",
|
||||
category="Broadcast",
|
||||
git_url="https://github.com/bastibl/gr-rds",
|
||||
branch="main",
|
||||
branch="maint-3.10",
|
||||
homepage="https://github.com/bastibl/gr-rds",
|
||||
preinstalled=True,
|
||||
),
|
||||
@ -217,6 +217,33 @@ CATALOG: dict[str, OOTModuleEntry] = {
|
||||
preinstalled=True,
|
||||
),
|
||||
# ── Installable via install_oot_module ──
|
||||
_entry(
|
||||
name="foo",
|
||||
description="Wireshark PCAP connector, burst tagger, periodic msg source",
|
||||
category="Utility",
|
||||
git_url="https://github.com/bastibl/gr-foo",
|
||||
branch="maint-3.10",
|
||||
build_deps=["castxml"],
|
||||
homepage="https://github.com/bastibl/gr-foo",
|
||||
),
|
||||
_entry(
|
||||
name="owc",
|
||||
description="Optical Wireless Communication channel simulation and modulation",
|
||||
category="Optical",
|
||||
git_url="https://github.com/UCaNLabUMB/gr-owc",
|
||||
branch="main",
|
||||
homepage="https://github.com/UCaNLabUMB/gr-owc",
|
||||
),
|
||||
_entry(
|
||||
name="dab",
|
||||
description="DAB/DAB+ digital audio broadcast receiver",
|
||||
category="Broadcast",
|
||||
git_url="https://github.com/hboeglen/gr-dab",
|
||||
branch="maint-3.10",
|
||||
build_deps=["autoconf", "automake", "libtool", "libfaad-dev"],
|
||||
cmake_args=["-DENABLE_DOXYGEN=OFF"],
|
||||
homepage="https://github.com/hboeglen/gr-dab",
|
||||
),
|
||||
_entry(
|
||||
name="lora_sdr",
|
||||
description="LoRa PHY transceiver (CSS modulation/demodulation)",
|
||||
@ -230,7 +257,8 @@ CATALOG: dict[str, OOTModuleEntry] = {
|
||||
description="IEEE 802.11a/g/p OFDM transceiver",
|
||||
category="WiFi",
|
||||
git_url="https://github.com/bastibl/gr-ieee802-11",
|
||||
branch="main",
|
||||
branch="maint-3.10",
|
||||
build_deps=["castxml"],
|
||||
homepage="https://github.com/bastibl/gr-ieee802-11",
|
||||
),
|
||||
_entry(
|
||||
@ -238,7 +266,8 @@ CATALOG: dict[str, OOTModuleEntry] = {
|
||||
description="IEEE 802.15.4 (Zigbee) O-QPSK transceiver",
|
||||
category="IoT",
|
||||
git_url="https://github.com/bastibl/gr-ieee802-15-4",
|
||||
branch="main",
|
||||
branch="maint-3.10",
|
||||
build_deps=["castxml"],
|
||||
homepage="https://github.com/bastibl/gr-ieee802-15-4",
|
||||
),
|
||||
_entry(
|
||||
@ -246,7 +275,7 @@ CATALOG: dict[str, OOTModuleEntry] = {
|
||||
description="ADS-B (1090 MHz) aircraft transponder decoder",
|
||||
category="Aviation",
|
||||
git_url="https://github.com/mhostetter/gr-adsb",
|
||||
branch="main",
|
||||
branch="maint-3.10",
|
||||
homepage="https://github.com/mhostetter/gr-adsb",
|
||||
),
|
||||
_entry(
|
||||
@ -254,33 +283,44 @@ CATALOG: dict[str, OOTModuleEntry] = {
|
||||
description="Iridium satellite burst detector and demodulator",
|
||||
category="Satellite",
|
||||
git_url="https://github.com/muccc/gr-iridium",
|
||||
branch="main",
|
||||
branch="master",
|
||||
homepage="https://github.com/muccc/gr-iridium",
|
||||
),
|
||||
_entry(
|
||||
name="leo",
|
||||
description="LEO satellite channel simulator (Doppler, path loss, atmosphere)",
|
||||
category="Satellite",
|
||||
git_url="https://gitlab.com/librespacefoundation/gr-leo",
|
||||
branch="gnuradio-3.10",
|
||||
homepage="https://gitlab.com/librespacefoundation/gr-leo",
|
||||
),
|
||||
_entry(
|
||||
name="dl5eu",
|
||||
description="DVB-T OFDM synchronization and TPS decoder",
|
||||
category="Broadcast",
|
||||
git_url="https://github.com/dl5eu/gr-dl5eu",
|
||||
branch="main",
|
||||
homepage="https://github.com/dl5eu/gr-dl5eu",
|
||||
),
|
||||
_entry(
|
||||
name="inspector",
|
||||
description="Signal analysis toolbox (energy detection, OFDM estimation)",
|
||||
category="Analysis",
|
||||
git_url="https://github.com/gnuradio/gr-inspector",
|
||||
branch="master",
|
||||
build_deps=["qtbase5-dev", "libqwt-qt5-dev"],
|
||||
homepage="https://github.com/gnuradio/gr-inspector",
|
||||
gr_versions="3.9 (master branch has API compat issues with 3.10)",
|
||||
),
|
||||
_entry(
|
||||
name="nrsc5",
|
||||
description="HD Radio (NRSC-5) digital broadcast decoder",
|
||||
category="Broadcast",
|
||||
git_url="https://github.com/argilo/gr-nrsc5",
|
||||
branch="main",
|
||||
branch="master",
|
||||
build_deps=["autoconf", "automake", "libtool"],
|
||||
homepage="https://github.com/argilo/gr-nrsc5",
|
||||
),
|
||||
_entry(
|
||||
name="packet_radio",
|
||||
description="Amateur packet radio (AFSK, AX.25) modem",
|
||||
category="Amateur",
|
||||
git_url="https://github.com/duggabe/gr-packet-radio",
|
||||
branch="main",
|
||||
homepage="https://github.com/duggabe/gr-packet-radio",
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Callable
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp import Context, FastMCP
|
||||
from pydantic import BaseModel
|
||||
|
||||
from gnuradio_mcp.middlewares.docker import DockerMiddleware
|
||||
from gnuradio_mcp.middlewares.oot import OOTInstallerMiddleware
|
||||
@ -11,81 +13,361 @@ from gnuradio_mcp.providers.runtime import RuntimeProvider
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RuntimeModeStatus(BaseModel):
|
||||
"""Status of runtime mode and available capabilities."""
|
||||
|
||||
enabled: bool
|
||||
tools_registered: list[str]
|
||||
docker_available: bool
|
||||
oot_available: bool
|
||||
|
||||
|
||||
class RootsCapability(BaseModel):
|
||||
"""Client roots capability - expose workspace directories to servers."""
|
||||
|
||||
supported: bool = False
|
||||
list_changed: bool | None = None # Client emits notifications when roots change
|
||||
|
||||
|
||||
class SamplingCapability(BaseModel):
|
||||
"""Client sampling capability - let servers request LLM completions.
|
||||
|
||||
Enables recursive agent patterns where servers can invoke the client's LLM.
|
||||
"""
|
||||
|
||||
supported: bool = False
|
||||
tools: bool = False # Allow tool use during sampling
|
||||
context: bool = False # Include context from other servers (deprecated)
|
||||
|
||||
|
||||
class ElicitationCapability(BaseModel):
|
||||
"""Client elicitation capability - servers can prompt users for input.
|
||||
|
||||
Form mode: collect structured data via forms
|
||||
URL mode: redirect to URLs for OAuth/payment/sensitive data
|
||||
"""
|
||||
|
||||
supported: bool = False
|
||||
form: bool = False # In-band structured data collection
|
||||
url: bool = False # Out-of-band URL navigation for sensitive flows
|
||||
|
||||
|
||||
class ClientCapabilities(BaseModel):
|
||||
"""MCP client capability information from initialize handshake.
|
||||
|
||||
Based on MCP spec 2025-11-25. Clients advertise which features they support:
|
||||
- roots: Expose workspace/project directories
|
||||
- sampling: Let servers request LLM completions
|
||||
- elicitation: Let servers prompt users for input
|
||||
"""
|
||||
|
||||
client_name: str | None = None
|
||||
client_version: str | None = None
|
||||
protocol_version: str | None = None
|
||||
|
||||
# Structured capability objects
|
||||
roots: RootsCapability = RootsCapability()
|
||||
sampling: SamplingCapability = SamplingCapability()
|
||||
elicitation: ElicitationCapability = ElicitationCapability()
|
||||
|
||||
# Raw capability dict for any unknown/future capabilities
|
||||
raw_capabilities: dict[str, Any] = {}
|
||||
experimental: dict[str, Any] = {}
|
||||
|
||||
|
||||
class ClientRoot(BaseModel):
|
||||
"""A root directory advertised by the MCP client."""
|
||||
|
||||
uri: str
|
||||
name: str | None = None
|
||||
|
||||
|
||||
class McpRuntimeProvider:
|
||||
"""Registers runtime control tools with FastMCP.
|
||||
|
||||
Docker is optional: if unavailable, container lifecycle and visual
|
||||
feedback tools are skipped, but XML-RPC connection/control tools
|
||||
are still registered (for connecting to externally-managed flowgraphs).
|
||||
Uses dynamic tool registration to minimize context usage:
|
||||
- At startup: only mode control tools are registered
|
||||
- When runtime mode is enabled: all runtime tools are registered
|
||||
- When disabled: runtime tools are removed
|
||||
|
||||
This keeps the tool list small when only doing flowgraph design,
|
||||
and expands it when connecting to SDR hardware or running flowgraphs.
|
||||
"""
|
||||
|
||||
def __init__(self, mcp_instance: FastMCP, runtime_provider: RuntimeProvider):
|
||||
self._mcp = mcp_instance
|
||||
self._provider = runtime_provider
|
||||
self.__init_tools()
|
||||
self._runtime_tools: dict[str, Callable] = {}
|
||||
self._runtime_enabled = False
|
||||
self.__init_mode_tools()
|
||||
self.__init_resources()
|
||||
|
||||
def __init_tools(self):
|
||||
def __init_mode_tools(self):
|
||||
"""Register only the mode control tools at startup."""
|
||||
|
||||
@self._mcp.tool
|
||||
def get_runtime_mode() -> RuntimeModeStatus:
|
||||
"""Check if runtime mode is enabled and what capabilities are available.
|
||||
|
||||
Runtime mode provides tools for:
|
||||
- Connecting to running flowgraphs (XML-RPC, ControlPort)
|
||||
- Launching flowgraphs in Docker containers
|
||||
- Installing OOT modules
|
||||
- Controlling SDR hardware
|
||||
|
||||
Call enable_runtime_mode() to register these tools.
|
||||
"""
|
||||
return RuntimeModeStatus(
|
||||
enabled=self._runtime_enabled,
|
||||
tools_registered=list(self._runtime_tools.keys()),
|
||||
docker_available=self._provider._has_docker,
|
||||
oot_available=self._provider._has_oot,
|
||||
)
|
||||
|
||||
@self._mcp.tool
|
||||
def enable_runtime_mode() -> RuntimeModeStatus:
|
||||
"""Enable runtime mode, registering all runtime control tools.
|
||||
|
||||
This adds tools for:
|
||||
- XML-RPC connection and variable control
|
||||
- ControlPort/Thrift for performance monitoring
|
||||
- Docker container lifecycle (if Docker available)
|
||||
- OOT module installation (if Docker available)
|
||||
|
||||
Use this when you need to:
|
||||
- Connect to a running flowgraph
|
||||
- Launch flowgraphs in containers
|
||||
- Control SDR hardware
|
||||
- Monitor performance
|
||||
"""
|
||||
if self._runtime_enabled:
|
||||
return RuntimeModeStatus(
|
||||
enabled=True,
|
||||
tools_registered=list(self._runtime_tools.keys()),
|
||||
docker_available=self._provider._has_docker,
|
||||
oot_available=self._provider._has_oot,
|
||||
)
|
||||
|
||||
self._register_runtime_tools()
|
||||
self._runtime_enabled = True
|
||||
|
||||
logger.info(
|
||||
"Runtime mode enabled: registered %d tools",
|
||||
len(self._runtime_tools),
|
||||
)
|
||||
|
||||
return RuntimeModeStatus(
|
||||
enabled=True,
|
||||
tools_registered=list(self._runtime_tools.keys()),
|
||||
docker_available=self._provider._has_docker,
|
||||
oot_available=self._provider._has_oot,
|
||||
)
|
||||
|
||||
@self._mcp.tool
|
||||
def disable_runtime_mode() -> RuntimeModeStatus:
|
||||
"""Disable runtime mode, removing runtime tools to reduce context.
|
||||
|
||||
Use this when you're done with runtime operations and want to
|
||||
reduce the tool list for flowgraph design work.
|
||||
"""
|
||||
if not self._runtime_enabled:
|
||||
return RuntimeModeStatus(
|
||||
enabled=False,
|
||||
tools_registered=[],
|
||||
docker_available=self._provider._has_docker,
|
||||
oot_available=self._provider._has_oot,
|
||||
)
|
||||
|
||||
self._unregister_runtime_tools()
|
||||
self._runtime_enabled = False
|
||||
|
||||
logger.info("Runtime mode disabled: removed runtime tools")
|
||||
|
||||
return RuntimeModeStatus(
|
||||
enabled=False,
|
||||
tools_registered=[],
|
||||
docker_available=self._provider._has_docker,
|
||||
oot_available=self._provider._has_oot,
|
||||
)
|
||||
|
||||
# Debug tools for MCP client inspection
|
||||
@self._mcp.tool
|
||||
async def get_client_capabilities(ctx: Context) -> ClientCapabilities:
|
||||
"""Get the connected MCP client's capabilities.
|
||||
|
||||
Returns information about the client including:
|
||||
- Client name and version (e.g., "claude-code" v2.1.15)
|
||||
- MCP protocol version
|
||||
- Supported capabilities (roots, sampling, etc.)
|
||||
- Experimental features
|
||||
|
||||
Useful for debugging MCP connections and understanding
|
||||
what features the client supports.
|
||||
"""
|
||||
session = ctx.session
|
||||
client_params = session.client_params if session else None
|
||||
|
||||
if client_params is None:
|
||||
return ClientCapabilities()
|
||||
|
||||
client_info = getattr(client_params, "clientInfo", None)
|
||||
caps = getattr(client_params, "capabilities", None)
|
||||
|
||||
result = ClientCapabilities(
|
||||
client_name=client_info.name if client_info else None,
|
||||
client_version=client_info.version if client_info else None,
|
||||
protocol_version=getattr(client_params, "protocolVersion", None),
|
||||
)
|
||||
|
||||
if caps:
|
||||
# Roots capability - workspace directory exposure
|
||||
if hasattr(caps, "roots") and caps.roots is not None:
|
||||
result.roots = RootsCapability(
|
||||
supported=True,
|
||||
list_changed=getattr(caps.roots, "listChanged", None),
|
||||
)
|
||||
result.raw_capabilities["roots"] = {
|
||||
"listChanged": getattr(caps.roots, "listChanged", None)
|
||||
}
|
||||
|
||||
# Sampling capability - server-initiated LLM requests
|
||||
if hasattr(caps, "sampling") and caps.sampling is not None:
|
||||
sampling_obj = caps.sampling
|
||||
result.sampling = SamplingCapability(
|
||||
supported=True,
|
||||
tools=hasattr(sampling_obj, "tools")
|
||||
and sampling_obj.tools is not None,
|
||||
context=hasattr(sampling_obj, "context")
|
||||
and sampling_obj.context is not None,
|
||||
)
|
||||
result.raw_capabilities["sampling"] = {
|
||||
"tools": result.sampling.tools,
|
||||
"context": result.sampling.context,
|
||||
}
|
||||
|
||||
# Elicitation capability - server-prompted user input
|
||||
if hasattr(caps, "elicitation") and caps.elicitation is not None:
|
||||
elicit_obj = caps.elicitation
|
||||
# Empty object means form-only (backwards compat)
|
||||
has_form = hasattr(elicit_obj, "form") and elicit_obj.form is not None
|
||||
has_url = hasattr(elicit_obj, "url") and elicit_obj.url is not None
|
||||
# If elicitation exists but no sub-caps, default to form
|
||||
if not has_form and not has_url:
|
||||
has_form = True
|
||||
result.elicitation = ElicitationCapability(
|
||||
supported=True,
|
||||
form=has_form,
|
||||
url=has_url,
|
||||
)
|
||||
result.raw_capabilities["elicitation"] = {
|
||||
"form": has_form,
|
||||
"url": has_url,
|
||||
}
|
||||
|
||||
# Experimental features
|
||||
if hasattr(caps, "experimental"):
|
||||
result.experimental = caps.experimental or {}
|
||||
|
||||
return result
|
||||
|
||||
@self._mcp.tool
|
||||
async def list_client_roots(ctx: Context) -> list[ClientRoot]:
|
||||
"""List the root directories advertised by the MCP client.
|
||||
|
||||
Roots represent project directories or workspaces the client
|
||||
wants the server to be aware of. Typically includes the
|
||||
current working directory.
|
||||
|
||||
Returns empty list if roots capability is not supported.
|
||||
"""
|
||||
try:
|
||||
roots = await ctx.list_roots()
|
||||
return [
|
||||
ClientRoot(uri=str(root.uri), name=root.name)
|
||||
for root in roots
|
||||
]
|
||||
except Exception as e:
|
||||
logger.warning("Failed to list client roots: %s", e)
|
||||
return []
|
||||
|
||||
logger.info(
|
||||
"Registered 5 mode control tools (runtime mode disabled by default)"
|
||||
)
|
||||
|
||||
def _register_runtime_tools(self):
|
||||
"""Dynamically register all runtime tools."""
|
||||
p = self._provider
|
||||
|
||||
# Connection management (always available)
|
||||
self._mcp.tool(p.connect)
|
||||
self._mcp.tool(p.disconnect)
|
||||
self._mcp.tool(p.get_status)
|
||||
# Connection management
|
||||
self._add_tool("connect", p.connect)
|
||||
self._add_tool("disconnect", p.disconnect)
|
||||
self._add_tool("get_status", p.get_status)
|
||||
|
||||
# Variable control (always available)
|
||||
self._mcp.tool(p.list_variables)
|
||||
self._mcp.tool(p.get_variable)
|
||||
self._mcp.tool(p.set_variable)
|
||||
# Variable control
|
||||
self._add_tool("list_variables", p.list_variables)
|
||||
self._add_tool("get_variable", p.get_variable)
|
||||
self._add_tool("set_variable", p.set_variable)
|
||||
|
||||
# Flowgraph execution (always available)
|
||||
self._mcp.tool(p.start)
|
||||
self._mcp.tool(p.stop)
|
||||
self._mcp.tool(p.lock)
|
||||
self._mcp.tool(p.unlock)
|
||||
# Flowgraph execution
|
||||
self._add_tool("start", p.start)
|
||||
self._add_tool("stop", p.stop)
|
||||
self._add_tool("lock", p.lock)
|
||||
self._add_tool("unlock", p.unlock)
|
||||
|
||||
# ControlPort/Thrift tools (always available - Phase 2)
|
||||
self._mcp.tool(p.connect_controlport)
|
||||
self._mcp.tool(p.disconnect_controlport)
|
||||
self._mcp.tool(p.get_knobs)
|
||||
self._mcp.tool(p.set_knobs)
|
||||
self._mcp.tool(p.get_knob_properties)
|
||||
self._mcp.tool(p.get_performance_counters)
|
||||
self._mcp.tool(p.post_message)
|
||||
# ControlPort/Thrift tools
|
||||
self._add_tool("connect_controlport", p.connect_controlport)
|
||||
self._add_tool("disconnect_controlport", p.disconnect_controlport)
|
||||
self._add_tool("get_knobs", p.get_knobs)
|
||||
self._add_tool("set_knobs", p.set_knobs)
|
||||
self._add_tool("get_knob_properties", p.get_knob_properties)
|
||||
self._add_tool("get_performance_counters", p.get_performance_counters)
|
||||
self._add_tool("post_message", p.post_message)
|
||||
|
||||
# Docker-dependent tools
|
||||
if p._has_docker:
|
||||
# Container lifecycle
|
||||
self._mcp.tool(p.launch_flowgraph)
|
||||
self._mcp.tool(p.list_containers)
|
||||
self._mcp.tool(p.stop_flowgraph)
|
||||
self._mcp.tool(p.remove_flowgraph)
|
||||
self._mcp.tool(p.connect_to_container)
|
||||
self._mcp.tool(p.connect_to_container_controlport) # Phase 2
|
||||
self._add_tool("launch_flowgraph", p.launch_flowgraph)
|
||||
self._add_tool("list_containers", p.list_containers)
|
||||
self._add_tool("stop_flowgraph", p.stop_flowgraph)
|
||||
self._add_tool("remove_flowgraph", p.remove_flowgraph)
|
||||
self._add_tool("connect_to_container", p.connect_to_container)
|
||||
self._add_tool(
|
||||
"connect_to_container_controlport", p.connect_to_container_controlport
|
||||
)
|
||||
|
||||
# Visual feedback
|
||||
self._mcp.tool(p.capture_screenshot)
|
||||
self._mcp.tool(p.get_container_logs)
|
||||
self._add_tool("capture_screenshot", p.capture_screenshot)
|
||||
self._add_tool("get_container_logs", p.get_container_logs)
|
||||
|
||||
# Coverage collection
|
||||
self._mcp.tool(p.collect_coverage)
|
||||
self._mcp.tool(p.generate_coverage_report)
|
||||
self._mcp.tool(p.combine_coverage)
|
||||
self._mcp.tool(p.delete_coverage)
|
||||
self._add_tool("collect_coverage", p.collect_coverage)
|
||||
self._add_tool("generate_coverage_report", p.generate_coverage_report)
|
||||
self._add_tool("combine_coverage", p.combine_coverage)
|
||||
self._add_tool("delete_coverage", p.delete_coverage)
|
||||
|
||||
# OOT module installation
|
||||
if p._has_oot:
|
||||
self._mcp.tool(p.install_oot_module)
|
||||
self._mcp.tool(p.list_oot_images)
|
||||
self._mcp.tool(p.remove_oot_image)
|
||||
logger.info("Registered 32 runtime tools (Docker + OOT available)")
|
||||
else:
|
||||
logger.info("Registered 29 runtime tools (Docker available)")
|
||||
else:
|
||||
logger.info(
|
||||
"Registered 17 runtime tools (Docker unavailable, "
|
||||
"container tools skipped)"
|
||||
)
|
||||
self._add_tool("detect_oot_modules", p.detect_oot_modules)
|
||||
self._add_tool("install_oot_module", p.install_oot_module)
|
||||
self._add_tool("list_oot_images", p.list_oot_images)
|
||||
self._add_tool("remove_oot_image", p.remove_oot_image)
|
||||
self._add_tool("build_multi_oot_image", p.build_multi_oot_image)
|
||||
self._add_tool("list_combo_images", p.list_combo_images)
|
||||
self._add_tool("remove_combo_image", p.remove_combo_image)
|
||||
|
||||
def _unregister_runtime_tools(self):
|
||||
"""Remove all dynamically registered runtime tools."""
|
||||
for name in list(self._runtime_tools.keys()):
|
||||
try:
|
||||
self._mcp.remove_tool(name)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to remove tool %s: %s", name, e)
|
||||
self._runtime_tools.clear()
|
||||
|
||||
def _add_tool(self, name: str, func: Callable):
|
||||
"""Add a tool and track it for later removal."""
|
||||
self._mcp.add_tool(func)
|
||||
self._runtime_tools[name] = func
|
||||
|
||||
def __init_resources(self):
|
||||
from gnuradio_mcp.oot_catalog import (
|
||||
|
||||
@ -12,12 +12,15 @@ from gnuradio_mcp.middlewares.oot import OOTInstallerMiddleware
|
||||
from gnuradio_mcp.middlewares.thrift import ThriftMiddleware
|
||||
from gnuradio_mcp.middlewares.xmlrpc import XmlRpcMiddleware
|
||||
from gnuradio_mcp.models import (
|
||||
ComboImageInfo,
|
||||
ComboImageResult,
|
||||
ConnectionInfoModel,
|
||||
ContainerModel,
|
||||
CoverageDataModel,
|
||||
CoverageReportModel,
|
||||
KnobModel,
|
||||
KnobPropertiesModel,
|
||||
OOTDetectionResult,
|
||||
OOTImageInfo,
|
||||
OOTInstallResult,
|
||||
PerfCounterModel,
|
||||
@ -107,6 +110,7 @@ class RuntimeProvider:
|
||||
enable_perf_counters: bool = True,
|
||||
device_paths: list[str] | None = None,
|
||||
image: str | None = None,
|
||||
auto_image: bool = False,
|
||||
) -> ContainerModel:
|
||||
"""Launch a flowgraph in a Docker container with Xvfb.
|
||||
|
||||
@ -121,8 +125,17 @@ class RuntimeProvider:
|
||||
enable_perf_counters: Enable performance counters (requires controlport)
|
||||
device_paths: Host device paths to pass through
|
||||
image: Docker image to use (e.g., 'gnuradio-lora-runtime:latest')
|
||||
auto_image: Automatically detect required OOT modules and build
|
||||
appropriate Docker image. If True and image is not specified,
|
||||
analyzes the flowgraph to determine OOT dependencies and
|
||||
builds a single-OOT or combo image as needed.
|
||||
"""
|
||||
docker = self._require_docker()
|
||||
|
||||
# Auto-detect and build image if requested
|
||||
if auto_image and image is None and self._has_oot:
|
||||
image = self._auto_select_image(flowgraph_path)
|
||||
|
||||
if name is None:
|
||||
name = f"gr-{Path(flowgraph_path).stem}"
|
||||
return docker.launch(
|
||||
@ -138,6 +151,50 @@ class RuntimeProvider:
|
||||
image=image,
|
||||
)
|
||||
|
||||
def _auto_select_image(self, flowgraph_path: str) -> str | None:
|
||||
"""Detect OOT modules and build/select appropriate image.
|
||||
|
||||
Auto-builds missing modules from catalog when needed.
|
||||
"""
|
||||
from gnuradio_mcp.oot_catalog import CATALOG
|
||||
|
||||
oot = self._require_oot()
|
||||
detection = oot.detect_required_modules(flowgraph_path)
|
||||
|
||||
if not detection.detected_modules:
|
||||
logger.info("No OOT modules detected, using base runtime image")
|
||||
return detection.recommended_image
|
||||
|
||||
modules = detection.detected_modules
|
||||
logger.info("Detected OOT modules: %s", modules)
|
||||
|
||||
if len(modules) == 1:
|
||||
# Single module - ensure it's built
|
||||
module = modules[0]
|
||||
if module not in oot._registry:
|
||||
entry = CATALOG.get(module)
|
||||
if entry:
|
||||
logger.info("Auto-building module '%s' from catalog", module)
|
||||
result = oot.build_module(
|
||||
git_url=entry.git_url,
|
||||
branch=entry.branch,
|
||||
build_deps=entry.build_deps or None,
|
||||
cmake_args=entry.cmake_args or None,
|
||||
)
|
||||
if not result.success:
|
||||
logger.error("Auto-build of '%s' failed: %s", module, result.error)
|
||||
return None
|
||||
info = oot._registry.get(module)
|
||||
return info.image_tag if info else None
|
||||
else:
|
||||
# Multiple modules - build combo
|
||||
logger.info("Building combo image for modules: %s", modules)
|
||||
result = oot.build_combo_image(modules)
|
||||
if result.success and result.image:
|
||||
return result.image.image_tag
|
||||
logger.error("Combo image build failed: %s", result.error)
|
||||
return None
|
||||
|
||||
def list_containers(self) -> list[ContainerModel]:
|
||||
"""List all gr-mcp managed containers."""
|
||||
docker = self._require_docker()
|
||||
@ -681,9 +738,37 @@ class RuntimeProvider:
|
||||
return deleted
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# OOT Module Installation
|
||||
# OOT Module Detection & Installation
|
||||
# ──────────────────────────────────────────
|
||||
|
||||
def detect_oot_modules(self, flowgraph_path: str) -> OOTDetectionResult:
|
||||
"""Detect which OOT modules a flowgraph requires.
|
||||
|
||||
Analyzes .py or .grc files to find OOT module dependencies.
|
||||
Returns recommended Docker image to use with launch_flowgraph().
|
||||
|
||||
For .py files: parses Python imports (most accurate)
|
||||
For .grc files: uses heuristic prefix matching against the
|
||||
OOT catalog (fast, no Docker required)
|
||||
|
||||
Args:
|
||||
flowgraph_path: Path to a .py or .grc flowgraph file
|
||||
|
||||
Returns:
|
||||
OOTDetectionResult with detected modules, unknown blocks,
|
||||
and recommended image tag.
|
||||
|
||||
Example:
|
||||
result = detect_oot_modules("lora_rx.grc")
|
||||
# -> detected_modules=["lora_sdr", "osmosdr"]
|
||||
# -> recommended_image="gr-combo-lora_sdr-osmosdr:latest"
|
||||
|
||||
# Then launch with auto-built image:
|
||||
launch_flowgraph("lora_rx.py", auto_image=True)
|
||||
"""
|
||||
oot = self._require_oot()
|
||||
return oot.detect_required_modules(flowgraph_path)
|
||||
|
||||
def install_oot_module(
|
||||
self,
|
||||
git_url: str,
|
||||
@ -718,3 +803,33 @@ class RuntimeProvider:
|
||||
"""Remove an OOT module image and its registry entry."""
|
||||
oot = self._require_oot()
|
||||
return oot.remove_image(module_name)
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# Multi-OOT Combo Images
|
||||
# ──────────────────────────────────────────
|
||||
|
||||
def build_multi_oot_image(
|
||||
self,
|
||||
module_names: list[str],
|
||||
force: bool = False,
|
||||
) -> ComboImageResult:
|
||||
"""Combine multiple OOT modules into a single Docker image.
|
||||
|
||||
Modules are merged using multi-stage Docker builds from existing
|
||||
single-OOT images. Missing modules that exist in the catalog
|
||||
are auto-built first.
|
||||
|
||||
Use the returned image_tag with launch_flowgraph().
|
||||
"""
|
||||
oot = self._require_oot()
|
||||
return oot.build_combo_image(module_names, force)
|
||||
|
||||
def list_combo_images(self) -> list[ComboImageInfo]:
|
||||
"""List all combined multi-OOT images."""
|
||||
oot = self._require_oot()
|
||||
return oot.list_combo_images()
|
||||
|
||||
def remove_combo_image(self, combo_key: str) -> bool:
|
||||
"""Remove a combined image by its combo key (e.g., 'combo:adsb+lora_sdr')."""
|
||||
oot = self._require_oot()
|
||||
return oot.remove_combo_image(combo_key)
|
||||
|
||||
@ -184,8 +184,13 @@ def runtime_mcp_app() -> FastMCP:
|
||||
|
||||
@pytest.fixture
|
||||
async def runtime_client(runtime_mcp_app: FastMCP):
|
||||
"""Create FastMCP client for runtime tools."""
|
||||
"""Create FastMCP client for runtime tools.
|
||||
|
||||
Automatically enables runtime mode so runtime tools are available.
|
||||
"""
|
||||
async with Client(runtime_mcp_app) as client:
|
||||
# Enable runtime mode to register runtime tools dynamically
|
||||
await client.call_tool(name="enable_runtime_mode")
|
||||
yield client
|
||||
|
||||
|
||||
@ -410,3 +415,97 @@ class TestRuntimeMcpToolsFullWorkflow:
|
||||
assert extract_raw_value(result) == freq
|
||||
|
||||
await runtime_client.call_tool(name="disconnect")
|
||||
|
||||
|
||||
class TestDynamicRuntimeMode:
|
||||
"""Test dynamic tool registration via runtime mode toggle."""
|
||||
|
||||
async def test_runtime_mode_starts_disabled(self, runtime_mcp_app: FastMCP):
|
||||
"""Runtime mode should be disabled by default."""
|
||||
async with Client(runtime_mcp_app) as client:
|
||||
result = await client.call_tool(name="get_runtime_mode")
|
||||
assert result.data.enabled is False
|
||||
assert result.data.tools_registered == []
|
||||
|
||||
async def test_enable_runtime_mode_registers_tools(self, runtime_mcp_app: FastMCP):
|
||||
"""Enabling runtime mode should register runtime tools."""
|
||||
async with Client(runtime_mcp_app) as client:
|
||||
# Check tools before enabling
|
||||
tools_before = await client.list_tools()
|
||||
|
||||
result = await client.call_tool(name="enable_runtime_mode")
|
||||
|
||||
assert result.data.enabled is True
|
||||
assert len(result.data.tools_registered) > 0
|
||||
assert "connect" in result.data.tools_registered
|
||||
assert "list_variables" in result.data.tools_registered
|
||||
|
||||
# Verify tools are actually callable
|
||||
tools_after = await client.list_tools()
|
||||
assert len(tools_after) > len(tools_before)
|
||||
|
||||
async def test_disable_runtime_mode_removes_tools(self, runtime_mcp_app: FastMCP):
|
||||
"""Disabling runtime mode should remove runtime tools."""
|
||||
async with Client(runtime_mcp_app) as client:
|
||||
# Enable first
|
||||
await client.call_tool(name="enable_runtime_mode")
|
||||
tools_enabled = await client.list_tools()
|
||||
|
||||
# Now disable
|
||||
result = await client.call_tool(name="disable_runtime_mode")
|
||||
|
||||
assert result.data.enabled is False
|
||||
assert result.data.tools_registered == []
|
||||
|
||||
# Verify tools are actually removed
|
||||
tools_disabled = await client.list_tools()
|
||||
assert len(tools_disabled) < len(tools_enabled)
|
||||
|
||||
async def test_enable_runtime_mode_idempotent(self, runtime_mcp_app: FastMCP):
|
||||
"""Enabling runtime mode twice should be safe."""
|
||||
async with Client(runtime_mcp_app) as client:
|
||||
result1 = await client.call_tool(name="enable_runtime_mode")
|
||||
result2 = await client.call_tool(name="enable_runtime_mode")
|
||||
|
||||
assert result1.data.enabled is True
|
||||
assert result2.data.enabled is True
|
||||
assert result1.data.tools_registered == result2.data.tools_registered
|
||||
|
||||
async def test_disable_runtime_mode_idempotent(self, runtime_mcp_app: FastMCP):
|
||||
"""Disabling runtime mode twice should be safe."""
|
||||
async with Client(runtime_mcp_app) as client:
|
||||
result1 = await client.call_tool(name="disable_runtime_mode")
|
||||
result2 = await client.call_tool(name="disable_runtime_mode")
|
||||
|
||||
assert result1.data.enabled is False
|
||||
assert result2.data.enabled is False
|
||||
|
||||
|
||||
class TestClientCapabilities:
|
||||
"""Test MCP client capability inspection tools."""
|
||||
|
||||
async def test_get_client_capabilities_returns_structured_data(
|
||||
self, runtime_mcp_app: FastMCP
|
||||
):
|
||||
"""get_client_capabilities should return structured capability info."""
|
||||
async with Client(runtime_mcp_app) as client:
|
||||
result = await client.call_tool(name="get_client_capabilities")
|
||||
|
||||
# Should have structured capability objects
|
||||
assert result.data is not None
|
||||
assert hasattr(result.data, "roots")
|
||||
assert hasattr(result.data, "sampling")
|
||||
assert hasattr(result.data, "elicitation")
|
||||
|
||||
# Each capability should have 'supported' field
|
||||
assert hasattr(result.data.roots, "supported")
|
||||
assert hasattr(result.data.sampling, "supported")
|
||||
assert hasattr(result.data.elicitation, "supported")
|
||||
|
||||
async def test_list_client_roots_returns_list(self, runtime_mcp_app: FastMCP):
|
||||
"""list_client_roots should return a list (may be empty in test)."""
|
||||
async with Client(runtime_mcp_app) as client:
|
||||
result = await client.call_tool(name="list_client_roots")
|
||||
|
||||
# Should return a list (FastMCP test client may not advertise roots)
|
||||
assert isinstance(result.data, list) or result.data is None
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"""Unit tests for OOT module installer middleware.
|
||||
|
||||
Tests Dockerfile generation, module name extraction, registry persistence,
|
||||
and image naming — all without requiring Docker.
|
||||
image naming, and OOT module detection — all without requiring Docker.
|
||||
"""
|
||||
|
||||
import json
|
||||
@ -11,7 +11,7 @@ from unittest.mock import MagicMock
|
||||
import pytest
|
||||
|
||||
from gnuradio_mcp.middlewares.oot import OOTInstallerMiddleware
|
||||
from gnuradio_mcp.models import OOTImageInfo
|
||||
from gnuradio_mcp.models import ComboImageInfo, OOTDetectionResult, OOTImageInfo
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@ -22,9 +22,11 @@ def mock_docker_client():
|
||||
@pytest.fixture
|
||||
def oot(mock_docker_client, tmp_path):
|
||||
mw = OOTInstallerMiddleware(mock_docker_client)
|
||||
# Override registry path to use tmp_path
|
||||
# Override registry paths to use tmp_path
|
||||
mw._registry_path = tmp_path / "oot-registry.json"
|
||||
mw._registry = {}
|
||||
mw._combo_registry_path = tmp_path / "oot-combo-registry.json"
|
||||
mw._combo_registry = {}
|
||||
return mw
|
||||
|
||||
|
||||
@ -94,7 +96,9 @@ class TestDockerfileGeneration:
|
||||
assert "FROM gnuradio-runtime:latest" in dockerfile
|
||||
assert "git clone --depth 1 --branch master" in dockerfile
|
||||
assert "https://github.com/tapparelj/gr-lora_sdr.git" in dockerfile
|
||||
assert "cd gr-lora_sdr && mkdir build" in dockerfile
|
||||
assert "cd gr-lora_sdr" in dockerfile
|
||||
assert "fix_binding_hashes.py" in dockerfile
|
||||
assert "mkdir build" in dockerfile
|
||||
assert "cmake -DCMAKE_INSTALL_PREFIX=/usr" in dockerfile
|
||||
assert "make -j$(nproc)" in dockerfile
|
||||
assert "ldconfig" in dockerfile
|
||||
@ -183,6 +187,44 @@ class TestRegistry:
|
||||
mw._save_registry()
|
||||
assert mw._registry_path.exists()
|
||||
|
||||
def test_load_skips_corrupt_entries(self, oot):
|
||||
"""Per-entry validation: one corrupt entry doesn't nuke valid ones."""
|
||||
data = {
|
||||
"good_module": {
|
||||
"module_name": "good_module",
|
||||
"image_tag": "gr-oot-good:main-abc1234",
|
||||
"git_url": "https://example.com/gr-good",
|
||||
"branch": "main",
|
||||
"git_commit": "abc1234",
|
||||
"base_image": "gnuradio-runtime:latest",
|
||||
"built_at": "2025-01-01T00:00:00+00:00",
|
||||
},
|
||||
"bad_module": {
|
||||
"image_id": "sha256:deadbeef",
|
||||
"build_deps": ["libfoo-dev"],
|
||||
},
|
||||
}
|
||||
oot._registry_path.write_text(json.dumps(data))
|
||||
loaded = oot._load_registry()
|
||||
assert "good_module" in loaded
|
||||
assert "bad_module" not in loaded
|
||||
|
||||
def test_build_module_reregisters_orphaned_image(self, oot):
|
||||
"""build_module re-registers when image exists but registry empty."""
|
||||
# Mock: _get_remote_commit returns a commit, _image_exists says yes
|
||||
oot._get_remote_commit = MagicMock(return_value="abc1234")
|
||||
oot._image_exists = MagicMock(return_value=True)
|
||||
|
||||
result = oot.build_module(
|
||||
git_url="https://github.com/example/gr-test",
|
||||
branch="main",
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert result.skipped is True
|
||||
assert "test" in oot._registry
|
||||
assert oot._registry["test"].image_tag == "gr-oot-test:main-abc1234"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# Image Naming
|
||||
@ -251,3 +293,555 @@ class TestRemoveImage:
|
||||
result = oot.remove_image("lora_sdr")
|
||||
assert result is True
|
||||
assert "lora_sdr" not in oot._registry
|
||||
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# Combo Key Generation
|
||||
# ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestComboKeyGeneration:
|
||||
def test_sorted_and_deduped(self):
|
||||
key = OOTInstallerMiddleware._combo_key(["lora_sdr", "adsb", "lora_sdr"])
|
||||
assert key == "combo:adsb+lora_sdr"
|
||||
|
||||
def test_alphabetical_order(self):
|
||||
key = OOTInstallerMiddleware._combo_key(["osmosdr", "adsb", "lora_sdr"])
|
||||
assert key == "combo:adsb+lora_sdr+osmosdr"
|
||||
|
||||
def test_single_module(self):
|
||||
key = OOTInstallerMiddleware._combo_key(["adsb"])
|
||||
assert key == "combo:adsb"
|
||||
|
||||
def test_two_modules(self):
|
||||
key = OOTInstallerMiddleware._combo_key(["lora_sdr", "adsb"])
|
||||
assert key == "combo:adsb+lora_sdr"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# Combo Image Tag
|
||||
# ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestComboImageTag:
|
||||
def test_format(self):
|
||||
tag = OOTInstallerMiddleware._combo_image_tag(["lora_sdr", "adsb"])
|
||||
assert tag == "gr-combo-adsb-lora_sdr:latest"
|
||||
|
||||
def test_sorted_and_deduped(self):
|
||||
tag = OOTInstallerMiddleware._combo_image_tag(
|
||||
["osmosdr", "adsb", "osmosdr"]
|
||||
)
|
||||
assert tag == "gr-combo-adsb-osmosdr:latest"
|
||||
|
||||
def test_three_modules(self):
|
||||
tag = OOTInstallerMiddleware._combo_image_tag(
|
||||
["lora_sdr", "adsb", "osmosdr"]
|
||||
)
|
||||
assert tag == "gr-combo-adsb-lora_sdr-osmosdr:latest"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# Combo Dockerfile Generation
|
||||
# ──────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_oot_info(name: str, tag: str) -> OOTImageInfo:
|
||||
"""Helper to create a minimal OOTImageInfo for testing."""
|
||||
return OOTImageInfo(
|
||||
module_name=name,
|
||||
image_tag=tag,
|
||||
git_url=f"https://example.com/gr-{name}.git",
|
||||
branch="main",
|
||||
git_commit="abc1234",
|
||||
base_image="gnuradio-runtime:latest",
|
||||
built_at="2025-01-01T00:00:00+00:00",
|
||||
)
|
||||
|
||||
|
||||
class TestComboDockerfileGeneration:
|
||||
def test_multi_stage_structure(self, oot):
|
||||
oot._registry["adsb"] = _make_oot_info("adsb", "gr-oot-adsb:main-abc1234")
|
||||
oot._registry["lora_sdr"] = _make_oot_info(
|
||||
"lora_sdr", "gr-oot-lora_sdr:master-def5678"
|
||||
)
|
||||
|
||||
dockerfile = oot.generate_combo_dockerfile(["lora_sdr", "adsb"])
|
||||
|
||||
# Stage aliases (sorted order: adsb first)
|
||||
assert "FROM gr-oot-adsb:main-abc1234 AS stage_adsb" in dockerfile
|
||||
assert "FROM gr-oot-lora_sdr:master-def5678 AS stage_lora_sdr" in dockerfile
|
||||
|
||||
# Final base image
|
||||
assert "FROM gnuradio-runtime:latest" in dockerfile
|
||||
|
||||
# COPY directives for both modules
|
||||
assert "COPY --from=stage_adsb /usr/lib/ /usr/lib/" in dockerfile
|
||||
assert "COPY --from=stage_adsb /usr/include/ /usr/include/" in dockerfile
|
||||
assert "COPY --from=stage_adsb /usr/share/gnuradio/ /usr/share/gnuradio/" in dockerfile
|
||||
assert "COPY --from=stage_lora_sdr /usr/lib/ /usr/lib/" in dockerfile
|
||||
assert "COPY --from=stage_lora_sdr /usr/include/ /usr/include/" in dockerfile
|
||||
|
||||
# Runtime setup
|
||||
assert "RUN ldconfig" in dockerfile
|
||||
assert "WORKDIR /flowgraphs" in dockerfile
|
||||
assert "PYTHONPATH" in dockerfile
|
||||
|
||||
def test_missing_module_raises(self, oot):
|
||||
oot._registry["adsb"] = _make_oot_info("adsb", "gr-oot-adsb:main-abc1234")
|
||||
|
||||
with pytest.raises(ValueError, match="lora_sdr"):
|
||||
oot.generate_combo_dockerfile(["adsb", "lora_sdr"])
|
||||
|
||||
def test_uses_configured_base_image(self, mock_docker_client, tmp_path):
|
||||
mw = OOTInstallerMiddleware(mock_docker_client, base_image="my-custom:v2")
|
||||
mw._registry_path = tmp_path / "oot-registry.json"
|
||||
mw._registry = {
|
||||
"adsb": _make_oot_info("adsb", "gr-oot-adsb:main-abc1234"),
|
||||
"lora_sdr": _make_oot_info("lora_sdr", "gr-oot-lora_sdr:main-def5678"),
|
||||
}
|
||||
mw._combo_registry_path = tmp_path / "oot-combo-registry.json"
|
||||
mw._combo_registry = {}
|
||||
|
||||
dockerfile = mw.generate_combo_dockerfile(["adsb", "lora_sdr"])
|
||||
assert "FROM my-custom:v2" in dockerfile
|
||||
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# Combo Registry Persistence
|
||||
# ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestComboRegistry:
|
||||
def test_separate_file(self, oot):
|
||||
"""Combo registry uses a different file from single-OOT registry."""
|
||||
assert oot._combo_registry_path != oot._registry_path
|
||||
assert "combo" in str(oot._combo_registry_path)
|
||||
|
||||
def test_empty_on_fresh_start(self, oot):
|
||||
assert oot._combo_registry == {}
|
||||
assert oot.list_combo_images() == []
|
||||
|
||||
def test_save_and_load_roundtrip(self, oot):
|
||||
info = ComboImageInfo(
|
||||
combo_key="combo:adsb+lora_sdr",
|
||||
image_tag="gr-combo-adsb-lora_sdr:latest",
|
||||
modules=[
|
||||
_make_oot_info("adsb", "gr-oot-adsb:main-abc1234"),
|
||||
_make_oot_info("lora_sdr", "gr-oot-lora_sdr:master-def5678"),
|
||||
],
|
||||
built_at="2025-01-01T00:00:00+00:00",
|
||||
)
|
||||
oot._combo_registry["combo:adsb+lora_sdr"] = info
|
||||
oot._save_combo_registry()
|
||||
|
||||
loaded = oot._load_combo_registry()
|
||||
assert "combo:adsb+lora_sdr" in loaded
|
||||
assert loaded["combo:adsb+lora_sdr"].image_tag == "gr-combo-adsb-lora_sdr:latest"
|
||||
assert len(loaded["combo:adsb+lora_sdr"].modules) == 2
|
||||
|
||||
def test_load_missing_file_returns_empty(self, oot):
|
||||
oot._combo_registry_path = oot._combo_registry_path.parent / "nope" / "r.json"
|
||||
result = oot._load_combo_registry()
|
||||
assert result == {}
|
||||
|
||||
def test_load_corrupt_file_returns_empty(self, oot):
|
||||
oot._combo_registry_path.write_text("broken{{{")
|
||||
result = oot._load_combo_registry()
|
||||
assert result == {}
|
||||
|
||||
def test_list_returns_values(self, oot):
|
||||
info = ComboImageInfo(
|
||||
combo_key="combo:adsb+lora_sdr",
|
||||
image_tag="gr-combo-adsb-lora_sdr:latest",
|
||||
modules=[],
|
||||
built_at="2025-01-01T00:00:00+00:00",
|
||||
)
|
||||
oot._combo_registry["combo:adsb+lora_sdr"] = info
|
||||
result = oot.list_combo_images()
|
||||
assert len(result) == 1
|
||||
assert result[0].combo_key == "combo:adsb+lora_sdr"
|
||||
|
||||
def test_remove_existing(self, oot, mock_docker_client):
|
||||
info = ComboImageInfo(
|
||||
combo_key="combo:adsb+lora_sdr",
|
||||
image_tag="gr-combo-adsb-lora_sdr:latest",
|
||||
modules=[],
|
||||
built_at="2025-01-01T00:00:00+00:00",
|
||||
)
|
||||
oot._combo_registry["combo:adsb+lora_sdr"] = info
|
||||
|
||||
result = oot.remove_combo_image("combo:adsb+lora_sdr")
|
||||
assert result is True
|
||||
assert "combo:adsb+lora_sdr" not in oot._combo_registry
|
||||
mock_docker_client.images.remove.assert_called_once_with(
|
||||
"gr-combo-adsb-lora_sdr:latest", force=True
|
||||
)
|
||||
|
||||
def test_remove_nonexistent(self, oot):
|
||||
result = oot.remove_combo_image("combo:nope+nada")
|
||||
assert result is False
|
||||
|
||||
def test_remove_survives_docker_error(self, oot, mock_docker_client):
|
||||
info = ComboImageInfo(
|
||||
combo_key="combo:adsb+lora_sdr",
|
||||
image_tag="gr-combo-adsb-lora_sdr:latest",
|
||||
modules=[],
|
||||
built_at="2025-01-01T00:00:00+00:00",
|
||||
)
|
||||
oot._combo_registry["combo:adsb+lora_sdr"] = info
|
||||
mock_docker_client.images.remove.side_effect = Exception("gone")
|
||||
|
||||
result = oot.remove_combo_image("combo:adsb+lora_sdr")
|
||||
assert result is True
|
||||
assert "combo:adsb+lora_sdr" not in oot._combo_registry
|
||||
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# Build Combo Image
|
||||
# ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildComboImage:
|
||||
def test_requires_at_least_two_modules(self, oot):
|
||||
result = oot.build_combo_image(["adsb"])
|
||||
assert result.success is False
|
||||
assert "2 distinct" in result.error
|
||||
|
||||
def test_rejects_duplicate_as_single(self, oot):
|
||||
result = oot.build_combo_image(["adsb", "adsb"])
|
||||
assert result.success is False
|
||||
assert "2 distinct" in result.error
|
||||
|
||||
def test_idempotent_skip(self, oot, mock_docker_client):
|
||||
"""Skips build if combo image already exists."""
|
||||
oot._registry["adsb"] = _make_oot_info("adsb", "gr-oot-adsb:main-abc")
|
||||
oot._registry["lora_sdr"] = _make_oot_info("lora_sdr", "gr-oot-lora:m-def")
|
||||
|
||||
existing = ComboImageInfo(
|
||||
combo_key="combo:adsb+lora_sdr",
|
||||
image_tag="gr-combo-adsb-lora_sdr:latest",
|
||||
modules=[],
|
||||
built_at="2025-01-01T00:00:00+00:00",
|
||||
)
|
||||
oot._combo_registry["combo:adsb+lora_sdr"] = existing
|
||||
|
||||
# Docker image exists
|
||||
mock_docker_client.images.get.return_value = MagicMock()
|
||||
|
||||
result = oot.build_combo_image(["lora_sdr", "adsb"])
|
||||
assert result.success is True
|
||||
assert result.skipped is True
|
||||
|
||||
def test_happy_path(self, oot, mock_docker_client):
|
||||
"""Builds combo from pre-existing single-OOT images."""
|
||||
oot._registry["adsb"] = _make_oot_info("adsb", "gr-oot-adsb:main-abc1234")
|
||||
oot._registry["lora_sdr"] = _make_oot_info(
|
||||
"lora_sdr", "gr-oot-lora_sdr:master-def5678"
|
||||
)
|
||||
|
||||
# Docker image does not exist yet
|
||||
mock_docker_client.images.get.side_effect = Exception("not found")
|
||||
# Mock successful build
|
||||
mock_docker_client.images.build.return_value = (
|
||||
MagicMock(),
|
||||
[{"stream": "Step 1/5 : FROM ...\n"}],
|
||||
)
|
||||
|
||||
result = oot.build_combo_image(["adsb", "lora_sdr"])
|
||||
assert result.success is True
|
||||
assert result.skipped is False
|
||||
assert result.image is not None
|
||||
assert result.image.combo_key == "combo:adsb+lora_sdr"
|
||||
assert result.image.image_tag == "gr-combo-adsb-lora_sdr:latest"
|
||||
assert len(result.image.modules) == 2
|
||||
assert result.modules_built == []
|
||||
|
||||
# Verify persisted to combo registry
|
||||
assert "combo:adsb+lora_sdr" in oot._combo_registry
|
||||
|
||||
def test_unknown_module_not_in_catalog(self, oot):
|
||||
"""Fails if module not in registry and not in catalog."""
|
||||
oot._registry["adsb"] = _make_oot_info("adsb", "gr-oot-adsb:main-abc1234")
|
||||
|
||||
result = oot.build_combo_image(["adsb", "totally_fake_module"])
|
||||
assert result.success is False
|
||||
assert "totally_fake_module" in result.error
|
||||
assert "not found in the catalog" in result.error
|
||||
|
||||
def test_force_rebuilds(self, oot, mock_docker_client):
|
||||
"""force=True bypasses idempotency check."""
|
||||
oot._registry["adsb"] = _make_oot_info("adsb", "gr-oot-adsb:main-abc")
|
||||
oot._registry["lora_sdr"] = _make_oot_info("lora_sdr", "gr-oot-lora:m-def")
|
||||
|
||||
existing = ComboImageInfo(
|
||||
combo_key="combo:adsb+lora_sdr",
|
||||
image_tag="gr-combo-adsb-lora_sdr:latest",
|
||||
modules=[],
|
||||
built_at="2025-01-01T00:00:00+00:00",
|
||||
)
|
||||
oot._combo_registry["combo:adsb+lora_sdr"] = existing
|
||||
|
||||
# Docker image exists
|
||||
mock_docker_client.images.get.return_value = MagicMock()
|
||||
mock_docker_client.images.build.return_value = (
|
||||
MagicMock(),
|
||||
[{"stream": "rebuilt\n"}],
|
||||
)
|
||||
|
||||
result = oot.build_combo_image(["adsb", "lora_sdr"], force=True)
|
||||
assert result.success is True
|
||||
assert result.skipped is False
|
||||
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# OOT Detection from Python Files
|
||||
# ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDetectFromPython:
|
||||
def test_detects_gnuradio_import(self, oot, tmp_path):
|
||||
"""Detects 'import gnuradio.MODULE' pattern."""
|
||||
py_file = tmp_path / "test.py"
|
||||
py_file.write_text(
|
||||
"import gnuradio.lora_sdr as lora_sdr\n"
|
||||
"from gnuradio import blocks\n"
|
||||
)
|
||||
result = oot.detect_required_modules(str(py_file))
|
||||
assert result.detected_modules == ["lora_sdr"]
|
||||
assert result.detection_method == "python_imports"
|
||||
|
||||
def test_detects_from_gnuradio_import(self, oot, tmp_path):
|
||||
"""Detects 'from gnuradio import MODULE' pattern."""
|
||||
py_file = tmp_path / "test.py"
|
||||
py_file.write_text("from gnuradio import adsb, blocks, analog\n")
|
||||
result = oot.detect_required_modules(str(py_file))
|
||||
assert result.detected_modules == ["adsb"]
|
||||
|
||||
def test_detects_multiple_modules(self, oot, tmp_path):
|
||||
"""Detects multiple OOT modules in one file."""
|
||||
py_file = tmp_path / "test.py"
|
||||
py_file.write_text(
|
||||
"import gnuradio.lora_sdr as lora_sdr\n"
|
||||
"from gnuradio import adsb\n"
|
||||
)
|
||||
result = oot.detect_required_modules(str(py_file))
|
||||
assert result.detected_modules == ["adsb", "lora_sdr"]
|
||||
|
||||
def test_detects_toplevel_osmosdr(self, oot, tmp_path):
|
||||
"""Detects top-level 'import osmosdr' (not in gnuradio namespace)."""
|
||||
py_file = tmp_path / "test.py"
|
||||
py_file.write_text("import osmosdr\n")
|
||||
result = oot.detect_required_modules(str(py_file))
|
||||
assert "osmosdr" in result.detected_modules
|
||||
|
||||
def test_detects_from_toplevel_import(self, oot, tmp_path):
|
||||
"""Detects 'from MODULE import ...' for top-level OOT."""
|
||||
py_file = tmp_path / "test.py"
|
||||
py_file.write_text("from osmosdr import source\n")
|
||||
result = oot.detect_required_modules(str(py_file))
|
||||
assert "osmosdr" in result.detected_modules
|
||||
|
||||
def test_ignores_core_modules(self, oot, tmp_path):
|
||||
"""Ignores core GNU Radio modules not in catalog."""
|
||||
py_file = tmp_path / "test.py"
|
||||
py_file.write_text(
|
||||
"from gnuradio import blocks, analog, gr, digital, filter\n"
|
||||
"import numpy\n"
|
||||
)
|
||||
result = oot.detect_required_modules(str(py_file))
|
||||
assert result.detected_modules == []
|
||||
|
||||
def test_no_oot_returns_empty(self, oot, tmp_path):
|
||||
"""Returns empty list when no OOT imports found."""
|
||||
py_file = tmp_path / "test.py"
|
||||
py_file.write_text("from gnuradio import blocks\nprint('hello')\n")
|
||||
result = oot.detect_required_modules(str(py_file))
|
||||
assert result.detected_modules == []
|
||||
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# OOT Detection from GRC Files
|
||||
# ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDetectFromGrc:
|
||||
def test_detects_prefixed_blocks(self, oot, tmp_path):
|
||||
"""Detects blocks with OOT module prefix."""
|
||||
grc_file = tmp_path / "test.grc"
|
||||
grc_file.write_text(
|
||||
"""
|
||||
blocks:
|
||||
- id: lora_sdr_gray_demap
|
||||
name: gray0
|
||||
- id: adsb_demod
|
||||
name: demod0
|
||||
- id: blocks_null_sink
|
||||
name: null0
|
||||
"""
|
||||
)
|
||||
result = oot.detect_required_modules(str(grc_file))
|
||||
assert result.detected_modules == ["adsb", "lora_sdr"]
|
||||
assert result.detection_method == "grc_prefix_heuristic"
|
||||
|
||||
def test_detects_exact_match_blocks(self, oot, tmp_path):
|
||||
"""Detects blocks that exactly match module name."""
|
||||
grc_file = tmp_path / "test.grc"
|
||||
grc_file.write_text(
|
||||
"""
|
||||
blocks:
|
||||
- id: osmosdr_source
|
||||
name: src0
|
||||
"""
|
||||
)
|
||||
result = oot.detect_required_modules(str(grc_file))
|
||||
assert "osmosdr" in result.detected_modules
|
||||
|
||||
def test_reports_unknown_blocks(self, oot, tmp_path):
|
||||
"""Reports blocks that look OOT but aren't in catalog."""
|
||||
grc_file = tmp_path / "test.grc"
|
||||
grc_file.write_text(
|
||||
"""
|
||||
blocks:
|
||||
- id: unknown_module_block
|
||||
name: blk0
|
||||
- id: another_weird_thing
|
||||
name: blk1
|
||||
"""
|
||||
)
|
||||
result = oot.detect_required_modules(str(grc_file))
|
||||
assert "unknown_module_block" in result.unknown_blocks
|
||||
assert "another_weird_thing" in result.unknown_blocks
|
||||
|
||||
def test_ignores_core_blocks(self, oot, tmp_path):
|
||||
"""Ignores core GNU Radio blocks."""
|
||||
grc_file = tmp_path / "test.grc"
|
||||
grc_file.write_text(
|
||||
"""
|
||||
blocks:
|
||||
- id: blocks_null_sink
|
||||
name: null0
|
||||
- id: analog_sig_source_x
|
||||
name: src0
|
||||
- id: digital_constellation_decoder_cb
|
||||
name: dec0
|
||||
- id: qtgui_time_sink_x
|
||||
name: time0
|
||||
- id: filter_fir_filter_xxx
|
||||
name: fir0
|
||||
"""
|
||||
)
|
||||
result = oot.detect_required_modules(str(grc_file))
|
||||
assert result.detected_modules == []
|
||||
assert result.unknown_blocks == []
|
||||
|
||||
def test_ignores_special_blocks(self, oot, tmp_path):
|
||||
"""Ignores variables, imports, and other special blocks."""
|
||||
grc_file = tmp_path / "test.grc"
|
||||
grc_file.write_text(
|
||||
"""
|
||||
blocks:
|
||||
- id: variable
|
||||
name: samp_rate
|
||||
- id: variable_qtgui_range
|
||||
name: freq
|
||||
- id: import
|
||||
name: import_0
|
||||
- id: options
|
||||
name: opts
|
||||
- id: pad_source
|
||||
name: in0
|
||||
- id: virtual_sink
|
||||
name: vsink0
|
||||
"""
|
||||
)
|
||||
result = oot.detect_required_modules(str(grc_file))
|
||||
assert result.detected_modules == []
|
||||
assert result.unknown_blocks == []
|
||||
|
||||
def test_handles_empty_blocks(self, oot, tmp_path):
|
||||
"""Handles GRC with no blocks."""
|
||||
grc_file = tmp_path / "test.grc"
|
||||
grc_file.write_text("blocks: []\n")
|
||||
result = oot.detect_required_modules(str(grc_file))
|
||||
assert result.detected_modules == []
|
||||
|
||||
def test_handles_missing_blocks_key(self, oot, tmp_path):
|
||||
"""Handles GRC without blocks key."""
|
||||
grc_file = tmp_path / "test.grc"
|
||||
grc_file.write_text("options:\n id: test\n")
|
||||
result = oot.detect_required_modules(str(grc_file))
|
||||
assert result.detected_modules == []
|
||||
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# Image Recommendation Logic
|
||||
# ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestRecommendImage:
|
||||
def test_recommends_base_for_no_modules(self, oot):
|
||||
"""Returns base runtime image when no OOT modules."""
|
||||
result = oot._recommend_image([])
|
||||
assert result == "gnuradio-runtime:latest"
|
||||
|
||||
def test_recommends_single_oot_image_if_built(self, oot):
|
||||
"""Returns single OOT image tag if already built."""
|
||||
oot._registry["lora_sdr"] = _make_oot_info(
|
||||
"lora_sdr", "gr-oot-lora_sdr:master-abc1234"
|
||||
)
|
||||
result = oot._recommend_image(["lora_sdr"])
|
||||
assert result == "gr-oot-lora_sdr:master-abc1234"
|
||||
|
||||
def test_returns_none_if_single_not_built(self, oot):
|
||||
"""Returns None if single module not yet built."""
|
||||
result = oot._recommend_image(["lora_sdr"])
|
||||
assert result is None
|
||||
|
||||
def test_recommends_combo_tag_if_exists(self, oot):
|
||||
"""Returns existing combo image tag."""
|
||||
combo_info = ComboImageInfo(
|
||||
combo_key="combo:adsb+lora_sdr",
|
||||
image_tag="gr-combo-adsb-lora_sdr:latest",
|
||||
modules=[],
|
||||
built_at="2025-01-01T00:00:00+00:00",
|
||||
)
|
||||
oot._combo_registry["combo:adsb+lora_sdr"] = combo_info
|
||||
result = oot._recommend_image(["adsb", "lora_sdr"])
|
||||
assert result == "gr-combo-adsb-lora_sdr:latest"
|
||||
|
||||
def test_recommends_combo_tag_format_if_not_built(self, oot):
|
||||
"""Returns what combo tag would be if not yet built."""
|
||||
result = oot._recommend_image(["adsb", "lora_sdr"])
|
||||
assert result == "gr-combo-adsb-lora_sdr:latest"
|
||||
|
||||
def test_combo_tag_sorted_alphabetically(self, oot):
|
||||
"""Combo tag always uses sorted module names."""
|
||||
result = oot._recommend_image(["lora_sdr", "adsb"])
|
||||
assert result == "gr-combo-adsb-lora_sdr:latest"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# Edge Cases and Error Handling
|
||||
# ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDetectionEdgeCases:
|
||||
def test_file_not_found(self, oot, tmp_path):
|
||||
"""Raises FileNotFoundError for missing file."""
|
||||
with pytest.raises(FileNotFoundError, match="Flowgraph not found"):
|
||||
oot.detect_required_modules(str(tmp_path / "does_not_exist.py"))
|
||||
|
||||
def test_unsupported_extension(self, oot, tmp_path):
|
||||
"""Raises ValueError for unsupported file type."""
|
||||
txt_file = tmp_path / "test.txt"
|
||||
txt_file.write_text("some content")
|
||||
with pytest.raises(ValueError, match="Unsupported file type"):
|
||||
oot.detect_required_modules(str(txt_file))
|
||||
|
||||
def test_result_includes_flowgraph_path(self, oot, tmp_path):
|
||||
"""Result includes the analyzed path."""
|
||||
py_file = tmp_path / "my_flowgraph.py"
|
||||
py_file.write_text("from gnuradio import blocks\n")
|
||||
result = oot.detect_required_modules(str(py_file))
|
||||
assert str(py_file) in result.flowgraph_path
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user