feat: auto-detect OOT modules from flowgraph files
Add detect_oot_modules() MCP tool that analyzes .py or .grc flowgraphs to identify required OOT modules, enabling automatic Docker image selection without manual image= parameter specification. Detection approaches: - Python files: parse import statements (gnuradio.module, import module) - GRC files: heuristic prefix matching + explicit block-to-module mappings New features: - OOTDetectionResult model with detected_modules, unknown_blocks, and recommended_image fields - auto_image parameter on launch_flowgraph() for automatic image selection - _BLOCK_TO_MODULE mapping for edge cases like lora_rx → lora_sdr - Comprehensive core block filtering (variable_*, filter blocks, xmlrpc) Tests: 23 new unit tests covering Python detection, GRC detection, image recommendation, and edge cases.
This commit is contained in:
parent
15c17aa0a0
commit
bf92c70d3b
@ -9,7 +9,15 @@ from datetime import datetime, timezone
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from gnuradio_mcp.models import ComboImageInfo, ComboImageResult, OOTImageInfo, OOTInstallResult
|
import re
|
||||||
|
|
||||||
|
from gnuradio_mcp.models import (
|
||||||
|
ComboImageInfo,
|
||||||
|
ComboImageResult,
|
||||||
|
OOTDetectionResult,
|
||||||
|
OOTImageInfo,
|
||||||
|
OOTInstallResult,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -549,3 +557,206 @@ class OOTInstallerMiddleware:
|
|||||||
self._combo_registry_path.parent.mkdir(parents=True, exist_ok=True)
|
self._combo_registry_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
data = {k: v.model_dump() for k, v in self._combo_registry.items()}
|
data = {k: v.model_dump() for k, v in self._combo_registry.items()}
|
||||||
self._combo_registry_path.write_text(json.dumps(data, indent=2))
|
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)
|
||||||
|
|||||||
@ -411,3 +411,17 @@ class ComboImageResult(BaseModel):
|
|||||||
error: str | None = None
|
error: str | None = None
|
||||||
skipped: bool = False # True if combo image already existed
|
skipped: bool = False # True if combo image already existed
|
||||||
modules_built: list[str] = [] # modules auto-built from catalog first
|
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)
|
||||||
|
|||||||
@ -75,6 +75,9 @@ class McpRuntimeProvider:
|
|||||||
|
|
||||||
# OOT module installation
|
# OOT module installation
|
||||||
if p._has_oot:
|
if p._has_oot:
|
||||||
|
# Detection (new!)
|
||||||
|
self._mcp.tool(p.detect_oot_modules)
|
||||||
|
# Installation
|
||||||
self._mcp.tool(p.install_oot_module)
|
self._mcp.tool(p.install_oot_module)
|
||||||
self._mcp.tool(p.list_oot_images)
|
self._mcp.tool(p.list_oot_images)
|
||||||
self._mcp.tool(p.remove_oot_image)
|
self._mcp.tool(p.remove_oot_image)
|
||||||
@ -82,7 +85,7 @@ class McpRuntimeProvider:
|
|||||||
self._mcp.tool(p.build_multi_oot_image)
|
self._mcp.tool(p.build_multi_oot_image)
|
||||||
self._mcp.tool(p.list_combo_images)
|
self._mcp.tool(p.list_combo_images)
|
||||||
self._mcp.tool(p.remove_combo_image)
|
self._mcp.tool(p.remove_combo_image)
|
||||||
logger.info("Registered 35 runtime tools (Docker + OOT available)")
|
logger.info("Registered 36 runtime tools (Docker + OOT available)")
|
||||||
else:
|
else:
|
||||||
logger.info("Registered 29 runtime tools (Docker available)")
|
logger.info("Registered 29 runtime tools (Docker available)")
|
||||||
else:
|
else:
|
||||||
|
|||||||
@ -20,6 +20,7 @@ from gnuradio_mcp.models import (
|
|||||||
CoverageReportModel,
|
CoverageReportModel,
|
||||||
KnobModel,
|
KnobModel,
|
||||||
KnobPropertiesModel,
|
KnobPropertiesModel,
|
||||||
|
OOTDetectionResult,
|
||||||
OOTImageInfo,
|
OOTImageInfo,
|
||||||
OOTInstallResult,
|
OOTInstallResult,
|
||||||
PerfCounterModel,
|
PerfCounterModel,
|
||||||
@ -109,6 +110,7 @@ class RuntimeProvider:
|
|||||||
enable_perf_counters: bool = True,
|
enable_perf_counters: bool = True,
|
||||||
device_paths: list[str] | None = None,
|
device_paths: list[str] | None = None,
|
||||||
image: str | None = None,
|
image: str | None = None,
|
||||||
|
auto_image: bool = False,
|
||||||
) -> ContainerModel:
|
) -> ContainerModel:
|
||||||
"""Launch a flowgraph in a Docker container with Xvfb.
|
"""Launch a flowgraph in a Docker container with Xvfb.
|
||||||
|
|
||||||
@ -123,8 +125,17 @@ class RuntimeProvider:
|
|||||||
enable_perf_counters: Enable performance counters (requires controlport)
|
enable_perf_counters: Enable performance counters (requires controlport)
|
||||||
device_paths: Host device paths to pass through
|
device_paths: Host device paths to pass through
|
||||||
image: Docker image to use (e.g., 'gnuradio-lora-runtime:latest')
|
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()
|
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:
|
if name is None:
|
||||||
name = f"gr-{Path(flowgraph_path).stem}"
|
name = f"gr-{Path(flowgraph_path).stem}"
|
||||||
return docker.launch(
|
return docker.launch(
|
||||||
@ -140,6 +151,50 @@ class RuntimeProvider:
|
|||||||
image=image,
|
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]:
|
def list_containers(self) -> list[ContainerModel]:
|
||||||
"""List all gr-mcp managed containers."""
|
"""List all gr-mcp managed containers."""
|
||||||
docker = self._require_docker()
|
docker = self._require_docker()
|
||||||
@ -683,9 +738,37 @@ class RuntimeProvider:
|
|||||||
return deleted
|
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(
|
def install_oot_module(
|
||||||
self,
|
self,
|
||||||
git_url: str,
|
git_url: str,
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
"""Unit tests for OOT module installer middleware.
|
"""Unit tests for OOT module installer middleware.
|
||||||
|
|
||||||
Tests Dockerfile generation, module name extraction, registry persistence,
|
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
|
import json
|
||||||
@ -11,7 +11,7 @@ from unittest.mock import MagicMock
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from gnuradio_mcp.middlewares.oot import OOTInstallerMiddleware
|
from gnuradio_mcp.middlewares.oot import OOTInstallerMiddleware
|
||||||
from gnuradio_mcp.models import ComboImageInfo, OOTImageInfo
|
from gnuradio_mcp.models import ComboImageInfo, OOTDetectionResult, OOTImageInfo
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@ -592,3 +592,256 @@ class TestBuildComboImage:
|
|||||||
result = oot.build_combo_image(["adsb", "lora_sdr"], force=True)
|
result = oot.build_combo_image(["adsb", "lora_sdr"], force=True)
|
||||||
assert result.success is True
|
assert result.success is True
|
||||||
assert result.skipped is False
|
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