Ryan Malloy 5db7d71d2b feat: add AI-assisted block development tools
Implements complete workflow for generating GNU Radio blocks from
descriptions:

Block Generation:
- generate_sync_block, generate_basic_block, generate_interp_block,
  generate_decim_block tools for creating different block types
- Template-based code generation with customizable work logic
- Automatic validation via AST parsing and signature checking

Protocol Analysis:
- Parse protocol specifications into structured models
- Generate decoder pipelines matching modulation to demodulator blocks
- Templates for BLE, Zigbee, LoRa, POCSAG, ADS-B protocols

OOT Export:
- Export generated blocks to full OOT module structure
- Generate CMakeLists.txt, block YAML, Python modules
- gr_modtool-compatible output

Dynamic Tool Registration:
- enable_block_dev_mode/disable_block_dev_mode for context management
- Tools only registered when needed (reduces LLM context usage)

Includes comprehensive test coverage and end-to-end demo.
2026-02-09 12:36:54 -07:00

727 lines
24 KiB
Python

"""OOT module export middleware.
Exports embedded Python blocks to full OOT module structure with
CMakeLists.txt, .block.yml, and Python package layout.
"""
from __future__ import annotations
import ast
import logging
import os
import re
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any
from gnuradio_mcp.models import (
GeneratedBlockCode,
OOTExportResult,
OOTSkeletonResult,
)
if TYPE_CHECKING:
from gnuradio_mcp.middlewares.flowgraph import FlowGraphMiddleware
logger = logging.getLogger(__name__)
# ──────────────────────────────────────────────
# File Templates
# ──────────────────────────────────────────────
ROOT_CMAKELISTS_TEMPLATE = '''cmake_minimum_required(VERSION 3.8)
project({module_name} CXX)
# Select the release build type by default
set(CMAKE_BUILD_TYPE "Release")
# Make sure our local CMake Modules path comes first
list(INSERT CMAKE_MODULE_PATH 0 ${{CMAKE_SOURCE_DIR}}/cmake/Modules)
# Find GNU Radio
find_package(Gnuradio "3.10" REQUIRED)
# Setup GNU Radio install directories
include(GrVersion)
# Set component
set(GR_{module_upper}_INCLUDE_DIRS ${{CMAKE_CURRENT_SOURCE_DIR}}/include)
set(GR_PKG_DOC_DIR ${{GR_DOC_DIR}}/${{CMAKE_PROJECT_NAME}}-${{VERSION_INFO_MAJOR}}.${{VERSION_INFO_API}}.${{VERSION_INFO_MINOR}})
########################################################################
# Setup the include and linker paths
########################################################################
include_directories(
${{CMAKE_SOURCE_DIR}}/lib
${{CMAKE_SOURCE_DIR}}/include
${{CMAKE_BINARY_DIR}}/lib
${{CMAKE_BINARY_DIR}}/include
${{Boost_INCLUDE_DIRS}}
${{GNURADIO_ALL_INCLUDE_DIRS}}
)
link_directories(
${{Boost_LIBRARY_DIRS}}
${{GNURADIO_RUNTIME_LIBRARY_DIRS}}
)
########################################################################
# Create uninstall target
########################################################################
configure_file(
${{CMAKE_SOURCE_DIR}}/cmake/cmake_uninstall.cmake.in
${{CMAKE_CURRENT_BINARY_DIR}}/cmake_uninstall.cmake
@ONLY)
add_custom_target(uninstall
${{CMAKE_COMMAND}} -P ${{CMAKE_CURRENT_BINARY_DIR}}/cmake_uninstall.cmake
)
########################################################################
# Add subdirectories
########################################################################
add_subdirectory(python/{module_name})
add_subdirectory(grc)
########################################################################
# Install cmake search helper
########################################################################
install(FILES cmake/Modules/{module_name}Config.cmake
DESTINATION ${{GR_LIBRARY_DIR}}/cmake/{module_name}
)
'''
PYTHON_CMAKELISTS_TEMPLATE = '''# Copyright {year} {author}
# SPDX-License-Identifier: GPL-3.0-or-later
########################################################################
# Include python install macros
########################################################################
include(GrPython)
########################################################################
# Install python sources
########################################################################
GR_PYTHON_INSTALL(
FILES
__init__.py
{block_files}
DESTINATION ${{GR_PYTHON_DIR}}/{module_name}
)
########################################################################
# Handle the unit tests
########################################################################
include(GrTest)
if(ENABLE_TESTING)
set(GR_TEST_TARGET_DEPS gnuradio-{module_name})
GR_ADD_TEST(qa_{module_name} ${{PYTHON_EXECUTABLE}} -B ${{CMAKE_CURRENT_SOURCE_DIR}}/qa_{module_name}.py)
endif()
'''
GRC_CMAKELISTS_TEMPLATE = '''# Copyright {year} {author}
# SPDX-License-Identifier: GPL-3.0-or-later
install(FILES
{yml_files}
DESTINATION ${{GR_DATA_DIR}}/grc/blocks
)
'''
PYTHON_INIT_TEMPLATE = '''#
# Copyright {year} {author}
# SPDX-License-Identifier: GPL-3.0-or-later
#
"""
GNU Radio {module_name} module
Generated by gr-mcp
"""
from importlib import import_module
# Import OOT blocks
{imports}
'''
BLOCK_YML_TEMPLATE = '''id: {module_name}_{block_name}
label: {block_label}
category: [{module_name}]
templates:
imports: from {module_name} import {block_name}
make: {module_name}.{block_name}({make_args})
{callbacks}
parameters:
{parameters}
inputs:
{inputs}
outputs:
{outputs}
documentation: |-
{documentation}
file_format: 1
'''
CMAKE_UNINSTALL_TEMPLATE = '''if(NOT EXISTS "@CMAKE_CURRENT_BINARY_DIR@/install_manifest.txt")
message(FATAL_ERROR "Cannot find install manifest: @CMAKE_CURRENT_BINARY_DIR@/install_manifest.txt")
endif(NOT EXISTS "@CMAKE_CURRENT_BINARY_DIR@/install_manifest.txt")
file(READ "@CMAKE_CURRENT_BINARY_DIR@/install_manifest.txt" files)
string(REGEX REPLACE "\\n" ";" files "${files}")
foreach(file ${files})
message(STATUS "Uninstalling $ENV{DESTDIR}${file}")
if(IS_SYMLINK "$ENV{DESTDIR}${file}" OR EXISTS "$ENV{DESTDIR}${file}")
exec_program(
"@CMAKE_COMMAND@" ARGS "-E remove \\"$ENV{DESTDIR}${file}\\""
OUTPUT_VARIABLE rm_out
RETURN_VALUE rm_retval
)
if(NOT "${rm_retval}" STREQUAL 0)
message(FATAL_ERROR "Problem when removing $ENV{DESTDIR}${file}")
endif(NOT "${rm_retval}" STREQUAL 0)
else(IS_SYMLINK "$ENV{DESTDIR}${file}" OR EXISTS "$ENV{DESTDIR}${file}")
message(STATUS "File $ENV{DESTDIR}${file} does not exist.")
endif(IS_SYMLINK "$ENV{DESTDIR}${file}" OR EXISTS "$ENV{DESTDIR}${file}")
endforeach(file)
'''
CMAKE_CONFIG_TEMPLATE = '''find_package(PkgConfig)
PKG_CHECK_MODULES(PC_{module_upper} QUIET {module_name})
if(PC_{module_upper}_FOUND)
set({module_upper}_FOUND TRUE)
set({module_upper}_INCLUDE_DIRS ${{PC_{module_upper}_INCLUDE_DIRS}})
set({module_upper}_LIBRARIES ${{PC_{module_upper}_LIBRARIES}})
else()
set({module_upper}_FOUND FALSE)
endif()
include(FindPackageHandleStandardArgs)
find_package_handle_standard_args({module_name} DEFAULT_MSG {module_upper}_FOUND)
mark_as_advanced({module_upper}_INCLUDE_DIRS {module_upper}_LIBRARIES)
'''
class OOTExporterMiddleware:
"""Exports embedded blocks to full OOT module structure.
Creates a gr_modtool-compatible directory structure that can be
built and installed as a standard GNU Radio OOT module.
"""
def __init__(self, flowgraph_mw: FlowGraphMiddleware | None = None):
"""Initialize the OOT exporter.
Args:
flowgraph_mw: Optional flowgraph middleware for accessing blocks.
"""
self._flowgraph_mw = flowgraph_mw
# ──────────────────────────────────────────
# Module Skeleton Generation
# ──────────────────────────────────────────
def generate_oot_skeleton(
self,
module_name: str,
output_dir: str,
author: str = "gr-mcp",
description: str = "",
) -> OOTSkeletonResult:
"""Generate an empty OOT module structure.
Creates the directory structure and CMake files for a new
GNU Radio OOT module. Blocks can be added later.
Args:
module_name: Module name (e.g., "custom" for gr-custom)
output_dir: Base directory for the module
author: Author name for copyright headers
description: Module description
Returns:
OOTSkeletonResult with paths and structure info.
"""
module_name = self._sanitize_module_name(module_name)
module_upper = module_name.upper()
year = datetime.now().year
# Create directory structure
base_path = Path(output_dir)
dirs = {
"root": base_path,
"cmake": base_path / "cmake" / "Modules",
"grc": base_path / "grc",
"python": base_path / "python" / module_name,
"include": base_path / "include" / module_name,
"lib": base_path / "lib",
}
try:
for d in dirs.values():
d.mkdir(parents=True, exist_ok=True)
files_created: dict[str, list[str]] = {k: [] for k in dirs.keys()}
# Root CMakeLists.txt
root_cmake = dirs["root"] / "CMakeLists.txt"
root_cmake.write_text(ROOT_CMAKELISTS_TEMPLATE.format(
module_name=module_name,
module_upper=module_upper,
))
files_created["root"].append("CMakeLists.txt")
# cmake/Modules/{module_name}Config.cmake
config_cmake = dirs["cmake"] / f"{module_name}Config.cmake"
config_cmake.write_text(CMAKE_CONFIG_TEMPLATE.format(
module_name=module_name,
module_upper=module_upper,
))
files_created["cmake"].append(f"{module_name}Config.cmake")
# cmake/cmake_uninstall.cmake.in
uninstall_cmake = dirs["root"] / "cmake" / "cmake_uninstall.cmake.in"
uninstall_cmake.write_text(CMAKE_UNINSTALL_TEMPLATE)
files_created["cmake"].append("cmake_uninstall.cmake.in")
# python/{module_name}/__init__.py
init_py = dirs["python"] / "__init__.py"
init_py.write_text(PYTHON_INIT_TEMPLATE.format(
year=year,
author=author,
module_name=module_name,
imports="",
))
files_created["python"].append("__init__.py")
# python/{module_name}/CMakeLists.txt
python_cmake = dirs["python"] / "CMakeLists.txt"
python_cmake.write_text(PYTHON_CMAKELISTS_TEMPLATE.format(
year=year,
author=author,
module_name=module_name,
block_files="",
))
files_created["python"].append("CMakeLists.txt")
# grc/CMakeLists.txt
grc_cmake = dirs["grc"] / "CMakeLists.txt"
grc_cmake.write_text(GRC_CMAKELISTS_TEMPLATE.format(
year=year,
author=author,
yml_files="",
))
files_created["grc"].append("CMakeLists.txt")
# README.md
readme = dirs["root"] / "README.md"
readme.write_text(f"# gr-{module_name}\n\n{description}\n\nGenerated by gr-mcp.\n")
files_created["root"].append("README.md")
return OOTSkeletonResult(
success=True,
module_name=module_name,
output_dir=str(base_path),
structure=files_created,
next_steps=[
"Add blocks using export_block_to_oot()",
"Build with: mkdir build && cd build && cmake .. && make",
"Install with: sudo make install",
],
)
except Exception as e:
logger.exception("Failed to generate OOT skeleton")
return OOTSkeletonResult(
success=False,
module_name=module_name,
output_dir=str(base_path),
next_steps=[f"Error: {e}"],
)
# ──────────────────────────────────────────
# Block Export
# ──────────────────────────────────────────
def export_block_to_oot(
self,
generated: GeneratedBlockCode,
module_name: str,
output_dir: str,
author: str = "gr-mcp",
) -> OOTExportResult:
"""Export a generated block to an OOT module.
Creates or updates an OOT module with the given block.
If the module doesn't exist, creates the skeleton first.
Args:
generated: GeneratedBlockCode from block generator
module_name: Module name (e.g., "custom")
output_dir: Base directory for the module
author: Author name for copyright headers
Returns:
OOTExportResult with file paths and status.
"""
module_name = self._sanitize_module_name(module_name)
block_name = self._sanitize_block_name(generated.block_name)
year = datetime.now().year
base_path = Path(output_dir)
files_created: list[str] = []
try:
# Create skeleton if needed
if not (base_path / "CMakeLists.txt").exists():
skeleton = self.generate_oot_skeleton(module_name, output_dir, author)
if not skeleton.success:
return OOTExportResult(
success=False,
module_name=module_name,
block_name=block_name,
output_dir=str(base_path),
error="Failed to create module skeleton",
)
files_created.extend(
[f"{k}/{f}" for k, files in skeleton.structure.items() for f in files]
)
# Write block Python file
python_dir = base_path / "python" / module_name
block_file = python_dir / f"{block_name}.py"
block_file.write_text(generated.source_code)
files_created.append(f"python/{module_name}/{block_name}.py")
# Update __init__.py
self._update_init_file(python_dir, block_name)
# Update python/CMakeLists.txt
self._update_python_cmake(python_dir, block_name)
# Generate and write .block.yml
grc_dir = base_path / "grc"
yml_content = self._generate_block_yml(
generated, module_name, block_name
)
yml_file = grc_dir / f"{module_name}_{block_name}.block.yml"
yml_file.write_text(yml_content)
files_created.append(f"grc/{module_name}_{block_name}.block.yml")
# Update grc/CMakeLists.txt
self._update_grc_cmake(grc_dir, module_name, block_name)
return OOTExportResult(
success=True,
module_name=module_name,
block_name=block_name,
output_dir=str(base_path),
files_created=files_created,
build_ready=True,
)
except Exception as e:
logger.exception("Failed to export block")
return OOTExportResult(
success=False,
module_name=module_name,
block_name=block_name,
output_dir=str(base_path),
error=str(e),
)
def export_from_flowgraph(
self,
block_name: str,
module_name: str,
output_dir: str,
author: str = "gr-mcp",
) -> OOTExportResult:
"""Export an embedded block from the current flowgraph.
Extracts the source code from an epy_block in the flowgraph
and exports it to a full OOT module.
Args:
block_name: Name of the epy_block in the flowgraph
module_name: Target module name
output_dir: Base directory for the module
author: Author name
Returns:
OOTExportResult with file paths and status.
"""
if self._flowgraph_mw is None:
return OOTExportResult(
success=False,
module_name=module_name,
block_name=block_name,
output_dir=output_dir,
error="No flowgraph middleware configured",
)
try:
# Get block from flowgraph
block_mw = self._flowgraph_mw.get_block(block_name)
block = block_mw._block
# Check it's an epy_block
if block.key != "epy_block":
return OOTExportResult(
success=False,
module_name=module_name,
block_name=block_name,
output_dir=output_dir,
error=f"Block {block_name} is not an epy_block (type: {block.key})",
)
# Extract source code
source_code = block.params["_source_code"].get_value()
# Parse to get block info
from gnuradio_mcp.models import SignatureItem
generated = GeneratedBlockCode(
source_code=source_code,
block_name=block_name,
block_class="sync_block",
inputs=[SignatureItem(dtype="float", vlen=1)],
outputs=[SignatureItem(dtype="float", vlen=1)],
is_valid=True,
)
return self.export_block_to_oot(generated, module_name, output_dir, author)
except Exception as e:
logger.exception("Failed to export from flowgraph")
return OOTExportResult(
success=False,
module_name=module_name,
block_name=block_name,
output_dir=output_dir,
error=str(e),
)
# ──────────────────────────────────────────
# Block YAML Generation
# ──────────────────────────────────────────
def _generate_block_yml(
self,
generated: GeneratedBlockCode,
module_name: str,
block_name: str,
) -> str:
"""Generate .block.yml content for a block."""
# Parse source to extract info
block_info = self._parse_block_source(generated.source_code)
# Build label
block_label = block_name.replace("_", " ").title()
# Build make args
make_args = ", ".join([
f"{p.name}=${{{p.name}}}" for p in generated.parameters
]) if generated.parameters else ""
# Build callbacks section
callbacks = ""
if block_info.get("callbacks"):
callbacks = "callbacks:\n" + "\n".join([
f" - set_{name}(${{{name}}})" for name in block_info["callbacks"]
])
# Build parameters section
params_yml = ""
for p in generated.parameters:
params_yml += f"""- id: {p.name}
label: {p.name.replace('_', ' ').title()}
dtype: {self._python_to_grc_dtype(p.dtype)}
default: {repr(p.default)}
"""
# Build inputs section
inputs_yml = ""
for i, inp in enumerate(generated.inputs):
inputs_yml += f"""- label: in{i}
domain: stream
dtype: {inp.dtype}
vlen: {inp.vlen}
"""
# Build outputs section
outputs_yml = ""
for i, out in enumerate(generated.outputs):
outputs_yml += f"""- label: out{i}
domain: stream
dtype: {out.dtype}
vlen: {out.vlen}
"""
# Documentation
doc = block_info.get("doc", generated.generation_prompt or "Custom block")
return BLOCK_YML_TEMPLATE.format(
module_name=module_name,
block_name=block_name,
block_label=block_label,
make_args=make_args,
callbacks=callbacks,
parameters=params_yml or "[]",
inputs=inputs_yml or "[]",
outputs=outputs_yml or "[]",
documentation=doc.replace("\n", "\n "),
)
def _parse_block_source(self, source_code: str) -> dict[str, Any]:
"""Parse block source to extract metadata."""
result: dict[str, Any] = {
"class_name": "blk",
"base_class": "gr.sync_block",
"doc": "",
"callbacks": [],
}
try:
tree = ast.parse(source_code)
for node in ast.walk(tree):
if isinstance(node, ast.ClassDef):
result["class_name"] = node.name
# Get docstring
if (node.body and isinstance(node.body[0], ast.Expr) and
isinstance(node.body[0].value, ast.Constant)):
result["doc"] = node.body[0].value.value
# Get base class
if node.bases:
result["base_class"] = ast.unparse(node.bases[0])
# Find setter methods (callbacks)
for item in node.body:
if isinstance(item, ast.FunctionDef):
if item.name.startswith("set_"):
param = item.name[4:] # Remove "set_" prefix
result["callbacks"].append(param)
break
except Exception as e:
logger.warning(f"Failed to parse block source: {e}")
return result
# ──────────────────────────────────────────
# File Update Helpers
# ──────────────────────────────────────────
def _update_init_file(self, python_dir: Path, block_name: str):
"""Update __init__.py to import the new block."""
init_file = python_dir / "__init__.py"
content = init_file.read_text()
import_line = f"from .{block_name} import blk as {block_name}"
if import_line not in content:
# Add import
if "# Import OOT blocks" in content:
content = content.replace(
"# Import OOT blocks",
f"# Import OOT blocks\n{import_line}"
)
else:
content += f"\n{import_line}\n"
init_file.write_text(content)
def _update_python_cmake(self, python_dir: Path, block_name: str):
"""Update python/CMakeLists.txt to include the new block."""
cmake_file = python_dir / "CMakeLists.txt"
content = cmake_file.read_text()
block_entry = f" {block_name}.py"
if block_entry not in content:
# Find FILES section and add
content = re.sub(
r"(FILES\s+\n\s+__init__\.py)",
f"\\1\n{block_entry}",
content,
)
cmake_file.write_text(content)
def _update_grc_cmake(self, grc_dir: Path, module_name: str, block_name: str):
"""Update grc/CMakeLists.txt to include the new .block.yml."""
cmake_file = grc_dir / "CMakeLists.txt"
content = cmake_file.read_text()
yml_entry = f" {module_name}_{block_name}.block.yml"
if yml_entry not in content:
# Find install(FILES section
if "install(FILES" in content:
content = re.sub(
r"(install\(FILES\s*\n)",
f"\\1{yml_entry}\n",
content,
)
else:
content = f"""install(FILES
{yml_entry}
DESTINATION ${{GR_DATA_DIR}}/grc/blocks
)
"""
cmake_file.write_text(content)
# ──────────────────────────────────────────
# Utility Methods
# ──────────────────────────────────────────
def _sanitize_module_name(self, name: str) -> str:
"""Sanitize module name for valid Python/CMake identifiers."""
# Remove gr- prefix if present
if name.lower().startswith("gr-"):
name = name[3:]
# Replace invalid characters
name = re.sub(r"[^a-zA-Z0-9_]", "_", name)
# Ensure starts with letter
if name and name[0].isdigit():
name = "m" + name
return name.lower()
def _sanitize_block_name(self, name: str) -> str:
"""Sanitize block name for valid Python identifier."""
# Remove any numeric suffix (e.g., _0)
name = re.sub(r"_\d+$", "", name)
# Replace invalid characters
name = re.sub(r"[^a-zA-Z0-9_]", "_", name)
# Ensure starts with letter
if name and name[0].isdigit():
name = "b" + name
return name.lower()
def _python_to_grc_dtype(self, dtype: str) -> str:
"""Convert Python type to GRC dtype string."""
mapping = {
"float": "real",
"int": "int",
"str": "string",
"bool": "bool",
"complex": "complex",
}
return mapping.get(dtype, "raw")