diff --git a/scripts/custom_tools/custom_tool.py b/scripts/custom_tools/custom_tool.py index e6a814b..d5c6b87 100644 --- a/scripts/custom_tools/custom_tool.py +++ b/scripts/custom_tools/custom_tool.py @@ -14,10 +14,6 @@ class SubCommand(InfuseCommand): - NAME = "custom_tool" - HELP = "Test out-of-tree tool" - DESCRIPTION = "Test out-of-tree tool" - @classmethod def add_parser(cls, parser): parser.add_argument("--echo", "-e", required=True, type=str) diff --git a/scripts/custom_tools/registry.py b/scripts/custom_tools/registry.py new file mode 100644 index 0000000..c2e3d15 --- /dev/null +++ b/scripts/custom_tools/registry.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python3 + +"""Example out-of-tree tool registry.""" + +from infuse_iot.tools.registry import ToolSpec + +TOOLS = ( + ToolSpec( + name="custom_tool", + help="Test out-of-tree tool", + description="Test out-of-tree tool", + module="custom_tool", + ), +) diff --git a/src/infuse_iot/app/main.py b/src/infuse_iot/app/main.py index 23e895e..9108573 100644 --- a/src/infuse_iot/app/main.py +++ b/src/infuse_iot/app/main.py @@ -7,20 +7,29 @@ __copyright__ = "Copyright 2024, Embeint Holdings Pty Ltd" import argparse +import importlib import importlib.util +import os import pathlib -import pkgutil import sys import types +from dataclasses import dataclass +from typing import Any import argcomplete +from argcomplete.lexers import split_line -import infuse_iot.tools -from infuse_iot.commands import InfuseCommand from infuse_iot.credentials import get_custom_tool_path +from infuse_iot.tools.registry import TOOLS, ToolSpec, load_extension_tools from infuse_iot.version import __version__ +@dataclass(frozen=True) +class RegisteredTool: + spec: ToolSpec + extension_path: pathlib.Path | None = None + + class InfuseApp: """The infuse 'application' object""" @@ -28,56 +37,105 @@ def __init__(self): self.args = None self.parser = argparse.ArgumentParser("infuse") self.parser.add_argument("--version", action="version", version=f"{__version__}") - self._tools = {} + self._tools: dict[str, RegisteredTool] = {} + self._tool_parsers = {} + self._loaded_tools = set() # Load tools self._load_tools(self.parser) - # Handle CLI tab completion - argcomplete.autocomplete(self.parser) def run(self, argv): """Run the chosen subtool handler""" + argv = argv or sys.argv[1:] + self._load_selected_tool(argv) + self._load_selected_completion_tool() + # Handle CLI tab completion + argcomplete.autocomplete(self.parser) self.args = self.parser.parse_args(argv) tool = self.args.tool_class(self.args) tool.run() - def _load_from_module(self, parent_parser: argparse._SubParsersAction, module: types.ModuleType): - tool_cls: InfuseCommand = module.SubCommand - parser = parent_parser.add_parser( - tool_cls.NAME, - help=tool_cls.HELP, - description=tool_cls.DESCRIPTION, - formatter_class=argparse.RawDescriptionHelpFormatter, - ) + def _load_from_module( + self, + parent_parser: argparse._SubParsersAction, + module: types.ModuleType, + parser: argparse.ArgumentParser | None = None, + ): + tool_cls: Any = module.SubCommand + if parser is None: + parser = parent_parser.add_parser( + tool_cls.NAME, + help=tool_cls.HELP, + description=tool_cls.DESCRIPTION, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) parser.set_defaults(tool_class=tool_cls) tool_cls.add_parser(parser) + def _load_selected_tool(self, argv: list[str]): + if not argv: + return + + if argv[0] not in self._tools or argv[0] in self._loaded_tools: + return + + tool = self._tools[argv[0]] + module = self._import_tool_module(tool) + self._load_from_module(self._tools_parser, module, self._tool_parsers[tool.spec.name]) + self._loaded_tools.add(tool.spec.name) + + def _import_tool_module(self, tool: RegisteredTool) -> types.ModuleType: + if tool.extension_path is None: + return importlib.import_module(tool.spec.module) + + module_path = tool.extension_path / f"{tool.spec.module.replace('.', '/')}.py" + module_name = f"infuse_iot_custom_tools.{tool.spec.module}" + spec = importlib.util.spec_from_file_location(module_name, module_path) + if spec is None or spec.loader is None: + raise ImportError(f"Failed to import custom tool module: {module_path}") + + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + def _load_selected_completion_tool(self): + if "_ARGCOMPLETE" not in os.environ: + return + + comp_line = os.environ["COMP_LINE"] + comp_point = int(os.environ["COMP_POINT"]) + _, _, _, comp_words, _ = split_line(comp_line, comp_point) + + # Match argcomplete's own executable/module offset handling. + start = int(os.environ["_ARGCOMPLETE"]) - 1 + parser_words = comp_words[start:] + if len(parser_words) > 1: + self._load_selected_tool(parser_words[1:]) + def _load_tools(self, parser: argparse.ArgumentParser): - tools_parser = parser.add_subparsers(title="commands", metavar="", required=True) + self._tools_parser = parser.add_subparsers(title="commands", metavar="", required=True) - # Iterate over local tools - for _, name, _ in pkgutil.walk_packages(infuse_iot.tools.__path__): - full_name = f"{infuse_iot.tools.__name__}.{name}" - module = importlib.import_module(full_name) - self._load_from_module(tools_parser, module) + # Register local tools without importing their implementation modules. + for tool in TOOLS: + self._register_tool(tool) # Load custom tools, if configured if extension_tools := get_custom_tool_path(): extension_path = pathlib.Path(extension_tools) - for _, name, _ in pkgutil.walk_packages([extension_tools]): - full_name = f"{infuse_iot.tools.__name__}.{name}" - full_path = str(extension_path / f"{name}.py") - spec = importlib.util.spec_from_file_location(full_name, full_path) - if spec is None or spec.loader is None: - continue - module = importlib.util.module_from_spec(spec) - try: - spec.loader.exec_module(module) - except Exception as e: - print(f"Failed to import '{name}': {str(e)}") - continue - if hasattr(module, "SubCommand"): - self._load_from_module(tools_parser, module) + for tool in load_extension_tools(extension_path): + self._register_tool(tool, extension_path) + + def _register_tool(self, tool: ToolSpec, extension_path: pathlib.Path | None = None): + if tool.name in self._tools: + raise ValueError(f"Tool already registered: {tool.name}") + + self._tools[tool.name] = RegisteredTool(tool, extension_path) + self._tool_parsers[tool.name] = self._tools_parser.add_parser( + tool.name, + help=tool.help, + description=tool.description, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) def main(argv=None): diff --git a/src/infuse_iot/commands.py b/src/infuse_iot/commands.py index 5c00516..0bb7db1 100644 --- a/src/infuse_iot/commands.py +++ b/src/infuse_iot/commands.py @@ -44,21 +44,6 @@ def __init__(self, args: argparse.Namespace): def run(self) -> None: """Run the subcommand""" - @property - @abstractmethod - def NAME(self) -> str: - pass - - @property - @abstractmethod - def HELP(self) -> str: - pass - - @property - @abstractmethod - def DESCRIPTION(self) -> str: - pass - class InfuseRpcCommand: RPC_DATA_SEND: bool = False diff --git a/src/infuse_iot/tools/annotate_events.py b/src/infuse_iot/tools/annotate_events.py index 61408dc..9bc9dce 100644 --- a/src/infuse_iot/tools/annotate_events.py +++ b/src/infuse_iot/tools/annotate_events.py @@ -45,10 +45,6 @@ class TimeCheckType(enum.Enum): class SubCommand(InfuseCommand): - NAME = "annotate_events" - HELP = "Annotate events on Infuse Tags" - DESCRIPTION = "Save labelled event annotations live on Infuse Tags" - _label_type: LabelType | Path _labels: list[str] _time_check: TimeCheckType diff --git a/src/infuse_iot/tools/audio_record.py b/src/infuse_iot/tools/audio_record.py index 00a75f4..e75996f 100644 --- a/src/infuse_iot/tools/audio_record.py +++ b/src/infuse_iot/tools/audio_record.py @@ -26,10 +26,6 @@ class SubCommand(InfuseCommand): - NAME = "audio_record" - HELP = "Record audio data to a file from TDF" - DESCRIPTION = "Record audio data to a file from TDF" - def __init__(self, args): self._client = LocalClient(args.server_sock, 1.0) self._decoder = TDF() diff --git a/src/infuse_iot/tools/auto_activate.py b/src/infuse_iot/tools/auto_activate.py index 6389910..126c8fa 100644 --- a/src/infuse_iot/tools/auto_activate.py +++ b/src/infuse_iot/tools/auto_activate.py @@ -24,10 +24,6 @@ class SubCommand(InfuseCommand): - NAME = "auto_activate" - HELP = "Automatically activate/deactivate observed devices" - DESCRIPTION = "Automatically activate/deactivate observed devices" - def __init__(self, args): self.app_ids = args.app self.active = args.active or False diff --git a/src/infuse_iot/tools/bt_log.py b/src/infuse_iot/tools/bt_log.py index 478a089..8bd554b 100644 --- a/src/infuse_iot/tools/bt_log.py +++ b/src/infuse_iot/tools/bt_log.py @@ -22,10 +22,6 @@ class SubCommand(InfuseCommand): - NAME = "bt_log" - HELP = "Connect to remote Bluetooth device serial logs" - DESCRIPTION = "Connect to remote Bluetooth device serial logs" - def __init__(self, args): self._client = LocalClient(args.server_sock, 1.0) self._decoder = TDF() diff --git a/src/infuse_iot/tools/cloud.py b/src/infuse_iot/tools/cloud.py index 0bc1b44..f931145 100644 --- a/src/infuse_iot/tools/cloud.py +++ b/src/infuse_iot/tools/cloud.py @@ -815,10 +815,6 @@ def upload_diffs_from_application( class SubCommand(InfuseCommand): - NAME = "cloud" - HELP = "Infuse-IoT cloud interaction" - DESCRIPTION = "Infuse-IoT cloud interaction" - @classmethod def add_parser(cls, parser): parser.add_argument("--api-key", type=str, help="Cloud API key to use instead of stored credentials") diff --git a/src/infuse_iot/tools/credentials.py b/src/infuse_iot/tools/credentials.py index 4f42fd6..01dc953 100644 --- a/src/infuse_iot/tools/credentials.py +++ b/src/infuse_iot/tools/credentials.py @@ -9,14 +9,11 @@ from infuse_iot import credentials from infuse_iot.commands import InfuseCommand +from infuse_iot.tools.registry import load_extension_tools from infuse_iot.util.argparse import ValidDir, ValidFile class SubCommand(InfuseCommand): - NAME = "credentials" - HELP = "Manage Infuse-IoT credentials" - DESCRIPTION = "Manage Infuse-IoT credentials" - @classmethod def add_parser(cls, parser): parser.add_argument("--api-key", type=str, help="Set Infuse-IoT API key") @@ -44,6 +41,7 @@ def run(self): network_info = yaml.safe_load(content) credentials.save_network(network_info["id"], content) if self.args.custom_tools: + load_extension_tools(self.args.custom_tools) credentials.set_custom_tool_path(str(self.args.custom_tools.absolute())) if self.args.custom_definitions: credentials.set_custom_definitions_path(str(self.args.custom_definitions.absolute())) diff --git a/src/infuse_iot/tools/csv_annotate.py b/src/infuse_iot/tools/csv_annotate.py index c39b4ef..bafeeb2 100644 --- a/src/infuse_iot/tools/csv_annotate.py +++ b/src/infuse_iot/tools/csv_annotate.py @@ -10,10 +10,6 @@ class SubCommand(InfuseCommand): - NAME = "csv_annotate" - HELP = "Annotate CSV data" - DESCRIPTION = "Annotate CSV data" - @classmethod def add_parser(cls, parser): parser.add_argument("--file", "-f", required=True, type=ValidFile) diff --git a/src/infuse_iot/tools/csv_plot.py b/src/infuse_iot/tools/csv_plot.py index 199dcc3..e58f965 100644 --- a/src/infuse_iot/tools/csv_plot.py +++ b/src/infuse_iot/tools/csv_plot.py @@ -10,10 +10,6 @@ class SubCommand(InfuseCommand): - NAME = "csv_plot" - HELP = "Plot CSV data" - DESCRIPTION = "Plot CSV data" - @classmethod def add_parser(cls, parser): parser.add_argument( diff --git a/src/infuse_iot/tools/data_logger_sync.py b/src/infuse_iot/tools/data_logger_sync.py index 54dc5c5..f7be1a2 100644 --- a/src/infuse_iot/tools/data_logger_sync.py +++ b/src/infuse_iot/tools/data_logger_sync.py @@ -59,10 +59,6 @@ def append_data(self, data: bytes): class SubCommand(InfuseCommand): - NAME = "data_logger_sync" - HELP = "Synchronise data logger state from remote devices" - DESCRIPTION = "Synchronise data logger state from remote devices" - def __init__(self, args): self._client = LocalClient(args.server_sock, 1.0) self._min_rssi: int | None = args.rssi diff --git a/src/infuse_iot/tools/gateway.py b/src/infuse_iot/tools/gateway.py index ccad1b1..ab6041f 100644 --- a/src/infuse_iot/tools/gateway.py +++ b/src/infuse_iot/tools/gateway.py @@ -451,10 +451,6 @@ def _iter(self) -> None: class SubCommand(InfuseCommand): - NAME = "gateway" - HELP = "Connect to a local gateway device" - DESCRIPTION = "Connect to a gateway device over serial and route commands to Bluetooth devices" - @classmethod def add_parser(cls, parser): # COM ports are not valid files diff --git a/src/infuse_iot/tools/localhost.py b/src/infuse_iot/tools/localhost.py index c530a7d..d683e07 100644 --- a/src/infuse_iot/tools/localhost.py +++ b/src/infuse_iot/tools/localhost.py @@ -35,10 +35,6 @@ class SubCommand(InfuseCommand): - NAME = "localhost" - HELP = "Run a local server for TDF viewing" - DESCRIPTION = "Run a local server for TDF viewing" - @classmethod def add_parser(cls, parser): parser.add_argument("--port", "-p", type=int, default=8080, help="Port number for localhost server") diff --git a/src/infuse_iot/tools/native_bt.py b/src/infuse_iot/tools/native_bt.py index f1008d2..26e507b 100644 --- a/src/infuse_iot/tools/native_bt.py +++ b/src/infuse_iot/tools/native_bt.py @@ -257,10 +257,6 @@ def connection_lost(self, exc): class SubCommand(InfuseCommand): - NAME = "native_bt" - HELP = "Native Bluetooth gateway" - DESCRIPTION = "Use the local Bluetooth adapter for Bluetooth interaction" - @classmethod def add_parser(cls, parser): parser.add_argument("--root", type=ValidFile, help="Root identity certificate to use instead of cloud") diff --git a/src/infuse_iot/tools/ota_upgrade.py b/src/infuse_iot/tools/ota_upgrade.py index 1d9b59b..556ca2e 100644 --- a/src/infuse_iot/tools/ota_upgrade.py +++ b/src/infuse_iot/tools/ota_upgrade.py @@ -36,10 +36,6 @@ class SubCommand(InfuseCommand): - NAME = "ota_upgrade" - HELP = "Automatically OTA upgrade observed devices" - DESCRIPTION = "Automatically OTA upgrade observed devices" - def __init__(self, args): self._client = LocalClient(args.server_sock, 1.0) self._conn_timeout = args.conn_timeout diff --git a/src/infuse_iot/tools/provision.py b/src/infuse_iot/tools/provision.py index 3a6e8ee..dd74a44 100644 --- a/src/infuse_iot/tools/provision.py +++ b/src/infuse_iot/tools/provision.py @@ -26,10 +26,6 @@ class SubCommand(InfuseCommand): - NAME = "provision" - HELP = "Provision device on Infuse Cloud" - DESCRIPTION = "Provision device on Infuse Cloud" - @classmethod def add_parser(cls, parser): vendor_group = parser.add_mutually_exclusive_group(required=True) diff --git a/src/infuse_iot/tools/registry.py b/src/infuse_iot/tools/registry.py new file mode 100644 index 0000000..1257082 --- /dev/null +++ b/src/infuse_iot/tools/registry.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 + +"""Tool registry definitions.""" + +import importlib.util +import pathlib +import types +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ToolSpec: + """Lightweight command metadata for parser construction.""" + + name: str + help: str + description: str + module: str + + +def _load_registry_module(path: pathlib.Path) -> types.ModuleType: + registry_path = path / "registry.py" + if not registry_path.exists(): + raise FileNotFoundError(f"Custom tools registry does not exist: {registry_path}") + + spec = importlib.util.spec_from_file_location("infuse_iot_custom_tools.registry", registry_path) + if spec is None or spec.loader is None: + raise ValueError(f"Failed to load custom tools registry: {registry_path}") + + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def load_extension_tools(path: str | pathlib.Path) -> tuple[ToolSpec, ...]: + """Load and validate ToolSpec entries from an extension tool directory.""" + extension_path = pathlib.Path(path) + module = _load_registry_module(extension_path) + + if not hasattr(module, "TOOLS"): + raise ValueError(f"Custom tools registry {extension_path / 'registry.py'} does not define TOOLS") + + tools = module.TOOLS + if not isinstance(tools, (list, tuple)): + raise TypeError("Custom tools registry TOOLS must be a list or tuple of ToolSpec entries") + + names = set() + validated_tools = [] + for tool in tools: + if not isinstance(tool, ToolSpec): + raise TypeError("Custom tools registry TOOLS must contain only ToolSpec entries") + if tool.name in names: + raise ValueError(f"Duplicate custom tool name: {tool.name}") + names.add(tool.name) + if not tool.name: + raise ValueError("Custom tool name cannot be empty") + if not tool.help: + raise ValueError(f"Custom tool {tool.name} help cannot be empty") + if not tool.description: + raise ValueError(f"Custom tool {tool.name} description cannot be empty") + if not tool.module: + raise ValueError(f"Custom tool {tool.name} module cannot be empty") + + module_path = extension_path / f"{tool.module.replace('.', '/')}.py" + if not module_path.exists(): + raise FileNotFoundError(f"Custom tool module does not exist: {module_path}") + validated_tools.append(tool) + + return tuple(validated_tools) + + +TOOLS = ( + ToolSpec( + name="annotate_events", + help="Annotate events on Infuse Tags", + description="Save labelled event annotations live on Infuse Tags", + module="infuse_iot.tools.annotate_events", + ), + ToolSpec( + name="audio_record", + help="Record audio data to a file from TDF", + description="Record audio data to a file from TDF", + module="infuse_iot.tools.audio_record", + ), + ToolSpec( + name="auto_activate", + help="Automatically activate/deactivate observed devices", + description="Automatically activate/deactivate observed devices", + module="infuse_iot.tools.auto_activate", + ), + ToolSpec( + name="bt_log", + help="Connect to remote Bluetooth device serial logs", + description="Connect to remote Bluetooth device serial logs", + module="infuse_iot.tools.bt_log", + ), + ToolSpec( + name="cloud", + help="Infuse-IoT cloud interaction", + description="Infuse-IoT cloud interaction", + module="infuse_iot.tools.cloud", + ), + ToolSpec( + name="credentials", + help="Manage Infuse-IoT credentials", + description="Manage Infuse-IoT credentials", + module="infuse_iot.tools.credentials", + ), + ToolSpec( + name="csv_annotate", + help="Annotate CSV data", + description="Annotate CSV data", + module="infuse_iot.tools.csv_annotate", + ), + ToolSpec( + name="csv_plot", + help="Plot CSV data", + description="Plot CSV data", + module="infuse_iot.tools.csv_plot", + ), + ToolSpec( + name="data_logger_sync", + help="Synchronise data logger state from remote devices", + description="Synchronise data logger state from remote devices", + module="infuse_iot.tools.data_logger_sync", + ), + ToolSpec( + name="gateway", + help="Connect to a local gateway device", + description="Connect to a gateway device over serial and route commands to Bluetooth devices", + module="infuse_iot.tools.gateway", + ), + ToolSpec( + name="localhost", + help="Run a local server for TDF viewing", + description="Run a local server for TDF viewing", + module="infuse_iot.tools.localhost", + ), + ToolSpec( + name="native_bt", + help="Native Bluetooth gateway", + description="Use the local Bluetooth adapter for Bluetooth interaction", + module="infuse_iot.tools.native_bt", + ), + ToolSpec( + name="ota_upgrade", + help="Automatically OTA upgrade observed devices", + description="Automatically OTA upgrade observed devices", + module="infuse_iot.tools.ota_upgrade", + ), + ToolSpec( + name="provision", + help="Provision device on Infuse Cloud", + description="Provision device on Infuse Cloud", + module="infuse_iot.tools.provision", + ), + ToolSpec( + name="rpc", + help="Run remote procedure calls on devices", + description="Run remote procedure calls on devices", + module="infuse_iot.tools.rpc", + ), + ToolSpec( + name="rpc_cloud", + help="Manage remote procedure calls through Infuse-IoT cloud", + description="Manage remote procedure calls through Infuse-IoT cloud", + module="infuse_iot.tools.rpc_cloud", + ), + ToolSpec( + name="serial_throughput", + help="Test serial throughput to local gateway", + description="Test serial throughput to local gateway", + module="infuse_iot.tools.serial_throughput", + ), + ToolSpec( + name="tdf_csv", + help="Save received TDFs in CSV files", + description="Save received TDFs in CSV files", + module="infuse_iot.tools.tdf_csv", + ), + ToolSpec( + name="tdf_list", + help="Display received TDFs in a list", + description="Display received TDFs in a list", + module="infuse_iot.tools.tdf_list", + ), +) diff --git a/src/infuse_iot/tools/rpc.py b/src/infuse_iot/tools/rpc.py index 59dd0d4..e9e6036 100644 --- a/src/infuse_iot/tools/rpc.py +++ b/src/infuse_iot/tools/rpc.py @@ -25,10 +25,6 @@ class SubCommand(InfuseCommand): - NAME = "rpc" - HELP = "Run remote procedure calls on devices" - DESCRIPTION = "Run remote procedure calls on devices" - @classmethod def add_parser(cls, parser): addr_group = parser.add_mutually_exclusive_group(required=True) diff --git a/src/infuse_iot/tools/rpc_cloud.py b/src/infuse_iot/tools/rpc_cloud.py index 6e6bb91..4eecc01 100644 --- a/src/infuse_iot/tools/rpc_cloud.py +++ b/src/infuse_iot/tools/rpc_cloud.py @@ -27,10 +27,6 @@ class SubCommand(InfuseCommand): - NAME = "rpc_cloud" - HELP = "Manage remote procedure calls through Infuse-IoT cloud" - DESCRIPTION = "Manage remote procedure calls through Infuse-IoT cloud" - @classmethod def add_parser(cls, parser): subparser = parser.add_subparsers(title="commands", metavar="", required=True) diff --git a/src/infuse_iot/tools/serial_throughput.py b/src/infuse_iot/tools/serial_throughput.py index 2d8f3da..93a00ae 100644 --- a/src/infuse_iot/tools/serial_throughput.py +++ b/src/infuse_iot/tools/serial_throughput.py @@ -25,10 +25,6 @@ class SubCommand(InfuseCommand): - NAME = "serial_throughput" - HELP = "Test serial throughput to local gateway" - DESCRIPTION = "Test serial throughput to local gateway" - @classmethod def add_parser(cls, parser): parser.add_argument( diff --git a/src/infuse_iot/tools/tdf_csv.py b/src/infuse_iot/tools/tdf_csv.py index 1d90bbc..29e0964 100644 --- a/src/infuse_iot/tools/tdf_csv.py +++ b/src/infuse_iot/tools/tdf_csv.py @@ -25,10 +25,6 @@ def _to_str(unix_time: float) -> str: class SubCommand(InfuseCommand): - NAME = "tdf_csv" - HELP = "Save received TDFs in CSV files" - DESCRIPTION = "Save received TDFs in CSV files" - @classmethod def add_parser(cls, parser): parser.add_argument("--unix", action="store_true", help="Save timestamps as unix") diff --git a/src/infuse_iot/tools/tdf_list.py b/src/infuse_iot/tools/tdf_list.py index 09d2e08..c82f582 100644 --- a/src/infuse_iot/tools/tdf_list.py +++ b/src/infuse_iot/tools/tdf_list.py @@ -23,10 +23,6 @@ class SubCommand(InfuseCommand): - NAME = "tdf_list" - HELP = "Display received TDFs in a list" - DESCRIPTION = "Display received TDFs in a list" - @classmethod def add_parser(cls, parser): parser.add_argument("--array-all", action="store_true", help="Display all array values, not just the last") diff --git a/tests/test_custom_tools.py b/tests/test_custom_tools.py index 4ba396e..8cd0a80 100644 --- a/tests/test_custom_tools.py +++ b/tests/test_custom_tools.py @@ -3,10 +3,12 @@ import os import pathlib import subprocess +import sys import pytest import infuse_iot.credentials as cred +from infuse_iot.app.main import InfuseApp assert "TOXTEMPDIR" in os.environ, "you must run these tests using tox" @@ -23,7 +25,7 @@ def test_custom_tool_integration(): with pytest.raises(subprocess.CalledProcessError): subprocess.check_output(["infuse", "custom_tool", "--echo", echo_string]) - custom_tools_path = pathlib.Path(__file__).parent.parent / 'scripts' / 'custom_tools' + custom_tools_path = pathlib.Path(__file__).parent.parent / "scripts" / "custom_tools" subprocess.check_output(["infuse", "credentials", "--custom-tools", str(custom_tools_path)]) @@ -34,3 +36,29 @@ def test_custom_tool_integration(): with pytest.raises(subprocess.CalledProcessError): subprocess.check_output(["infuse", "custom_tool", "--echo", echo_string]) + + +def test_custom_tool_path_requires_registry(tmp_path): + with pytest.raises(subprocess.CalledProcessError): + subprocess.check_output(["infuse", "credentials", "--custom-tools", str(tmp_path)]) + + +def test_extension_tool_registry_loading(): + custom_tools_path = pathlib.Path(__file__).parent.parent / "scripts" / "custom_tools" + + try: + cred.set_custom_tool_path(str(custom_tools_path)) + sys.modules.pop("infuse_iot_custom_tools.custom_tool", None) + + app = InfuseApp() + + assert "custom_tool" in app._tools + assert app._tools["custom_tool"].spec.module == "custom_tool" + assert "custom_tool" not in app._loaded_tools + assert "infuse_iot_custom_tools.custom_tool" not in sys.modules + + app._load_selected_tool(["custom_tool", "--echo", "test_string"]) + + assert "custom_tool" in app._loaded_tools + finally: + cred.delete_custom_tool_path() diff --git a/tests/test_help.py b/tests/test_help.py index 9e9d9e2..7322e97 100644 --- a/tests/test_help.py +++ b/tests/test_help.py @@ -16,3 +16,18 @@ def test_help(): subprocess.check_output([sys.executable, "-m", "infuse_iot", "--help"]) subprocess.check_output(["infuse", "--help"]) + + +def test_completion_loads_selected_tool(monkeypatch): + from infuse_iot.app.main import InfuseApp + + command_line = "infuse credentials --api" + monkeypatch.setenv("COMP_LINE", command_line) + monkeypatch.setenv("COMP_POINT", str(len(command_line))) + monkeypatch.setenv("_ARGCOMPLETE", "1") + + app = InfuseApp() + assert "infuse_iot.tools.credentials" not in sys.modules + + app._load_selected_completion_tool() + assert "infuse_iot.tools.credentials" in sys.modules