Skip to content

Commit b983f69

Browse files
committed
app: main: lazy loading of tools
Move tool discovery to a dedicated `registry.py` file, that allows tools to only be imported when necessary, instead of always importing all tools. Assisted-by: GPT-5.5 Signed-off-by: Jordan Yates <jordan@embeint.com>
1 parent adcc468 commit b983f69

26 files changed

Lines changed: 339 additions & 130 deletions

scripts/custom_tools/custom_tool.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,6 @@
1414

1515

1616
class SubCommand(InfuseCommand):
17-
NAME = "custom_tool"
18-
HELP = "Test out-of-tree tool"
19-
DESCRIPTION = "Test out-of-tree tool"
20-
2117
@classmethod
2218
def add_parser(cls, parser):
2319
parser.add_argument("--echo", "-e", required=True, type=str)

scripts/custom_tools/registry.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
#!/usr/bin/env python3
2+
3+
"""Example out-of-tree tool registry."""
4+
5+
from infuse_iot.tools.registry import ToolSpec
6+
7+
TOOLS = (
8+
ToolSpec(
9+
name="custom_tool",
10+
help="Test out-of-tree tool",
11+
description="Test out-of-tree tool",
12+
module="custom_tool",
13+
),
14+
)

src/infuse_iot/app/main.py

Lines changed: 92 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -7,77 +7,135 @@
77
__copyright__ = "Copyright 2024, Embeint Holdings Pty Ltd"
88

99
import argparse
10+
import importlib
1011
import importlib.util
12+
import os
1113
import pathlib
12-
import pkgutil
1314
import sys
1415
import types
16+
from dataclasses import dataclass
17+
from typing import Any
1518

1619
import argcomplete
20+
from argcomplete.lexers import split_line
1721

18-
import infuse_iot.tools
19-
from infuse_iot.commands import InfuseCommand
2022
from infuse_iot.credentials import get_custom_tool_path
23+
from infuse_iot.tools.registry import TOOLS, ToolSpec, load_extension_tools
2124
from infuse_iot.version import __version__
2225

2326

27+
@dataclass(frozen=True)
28+
class RegisteredTool:
29+
spec: ToolSpec
30+
extension_path: pathlib.Path | None = None
31+
32+
2433
class InfuseApp:
2534
"""The infuse 'application' object"""
2635

2736
def __init__(self):
2837
self.args = None
2938
self.parser = argparse.ArgumentParser("infuse")
3039
self.parser.add_argument("--version", action="version", version=f"{__version__}")
31-
self._tools = {}
40+
self._tools: dict[str, RegisteredTool] = {}
41+
self._tool_parsers = {}
42+
self._loaded_tools = set()
3243
# Load tools
3344
self._load_tools(self.parser)
34-
# Handle CLI tab completion
35-
argcomplete.autocomplete(self.parser)
3645

3746
def run(self, argv):
3847
"""Run the chosen subtool handler"""
48+
argv = argv or sys.argv[1:]
49+
self._load_selected_tool(argv)
50+
self._load_selected_completion_tool()
51+
# Handle CLI tab completion
52+
argcomplete.autocomplete(self.parser)
3953
self.args = self.parser.parse_args(argv)
4054

4155
tool = self.args.tool_class(self.args)
4256
tool.run()
4357

44-
def _load_from_module(self, parent_parser: argparse._SubParsersAction, module: types.ModuleType):
45-
tool_cls: InfuseCommand = module.SubCommand
46-
parser = parent_parser.add_parser(
47-
tool_cls.NAME,
48-
help=tool_cls.HELP,
49-
description=tool_cls.DESCRIPTION,
50-
formatter_class=argparse.RawDescriptionHelpFormatter,
51-
)
58+
def _load_from_module(
59+
self,
60+
parent_parser: argparse._SubParsersAction,
61+
module: types.ModuleType,
62+
parser: argparse.ArgumentParser | None = None,
63+
):
64+
tool_cls: Any = module.SubCommand
65+
if parser is None:
66+
parser = parent_parser.add_parser(
67+
tool_cls.NAME,
68+
help=tool_cls.HELP,
69+
description=tool_cls.DESCRIPTION,
70+
formatter_class=argparse.RawDescriptionHelpFormatter,
71+
)
5272
parser.set_defaults(tool_class=tool_cls)
5373
tool_cls.add_parser(parser)
5474

75+
def _load_selected_tool(self, argv: list[str]):
76+
if not argv:
77+
return
78+
79+
if argv[0] not in self._tools or argv[0] in self._loaded_tools:
80+
return
81+
82+
tool = self._tools[argv[0]]
83+
module = self._import_tool_module(tool)
84+
self._load_from_module(self._tools_parser, module, self._tool_parsers[tool.spec.name])
85+
self._loaded_tools.add(tool.spec.name)
86+
87+
def _import_tool_module(self, tool: RegisteredTool) -> types.ModuleType:
88+
if tool.extension_path is None:
89+
return importlib.import_module(tool.spec.module)
90+
91+
module_path = tool.extension_path / f"{tool.spec.module.replace('.', '/')}.py"
92+
module_name = f"infuse_iot_custom_tools.{tool.spec.module}"
93+
spec = importlib.util.spec_from_file_location(module_name, module_path)
94+
if spec is None or spec.loader is None:
95+
raise ImportError(f"Failed to import custom tool module: {module_path}")
96+
97+
module = importlib.util.module_from_spec(spec)
98+
spec.loader.exec_module(module)
99+
return module
100+
101+
def _load_selected_completion_tool(self):
102+
if "_ARGCOMPLETE" not in os.environ:
103+
return
104+
105+
comp_line = os.environ["COMP_LINE"]
106+
comp_point = int(os.environ["COMP_POINT"])
107+
_, _, _, comp_words, _ = split_line(comp_line, comp_point)
108+
109+
# Match argcomplete's own executable/module offset handling.
110+
start = int(os.environ["_ARGCOMPLETE"]) - 1
111+
parser_words = comp_words[start:]
112+
if len(parser_words) > 1:
113+
self._load_selected_tool(parser_words[1:])
114+
55115
def _load_tools(self, parser: argparse.ArgumentParser):
56-
tools_parser = parser.add_subparsers(title="commands", metavar="<command>", required=True)
116+
self._tools_parser = parser.add_subparsers(title="commands", metavar="<command>", required=True)
57117

58-
# Iterate over local tools
59-
for _, name, _ in pkgutil.walk_packages(infuse_iot.tools.__path__):
60-
full_name = f"{infuse_iot.tools.__name__}.{name}"
61-
module = importlib.import_module(full_name)
62-
self._load_from_module(tools_parser, module)
118+
# Register local tools without importing their implementation modules.
119+
for tool in TOOLS:
120+
self._register_tool(tool)
63121

64122
# Load custom tools, if configured
65123
if extension_tools := get_custom_tool_path():
66124
extension_path = pathlib.Path(extension_tools)
67-
for _, name, _ in pkgutil.walk_packages([extension_tools]):
68-
full_name = f"{infuse_iot.tools.__name__}.{name}"
69-
full_path = str(extension_path / f"{name}.py")
70-
spec = importlib.util.spec_from_file_location(full_name, full_path)
71-
if spec is None or spec.loader is None:
72-
continue
73-
module = importlib.util.module_from_spec(spec)
74-
try:
75-
spec.loader.exec_module(module)
76-
except Exception as e:
77-
print(f"Failed to import '{name}': {str(e)}")
78-
continue
79-
if hasattr(module, "SubCommand"):
80-
self._load_from_module(tools_parser, module)
125+
for tool in load_extension_tools(extension_path):
126+
self._register_tool(tool, extension_path)
127+
128+
def _register_tool(self, tool: ToolSpec, extension_path: pathlib.Path | None = None):
129+
if tool.name in self._tools:
130+
raise ValueError(f"Tool already registered: {tool.name}")
131+
132+
self._tools[tool.name] = RegisteredTool(tool, extension_path)
133+
self._tool_parsers[tool.name] = self._tools_parser.add_parser(
134+
tool.name,
135+
help=tool.help,
136+
description=tool.description,
137+
formatter_class=argparse.RawDescriptionHelpFormatter,
138+
)
81139

82140

83141
def main(argv=None):

src/infuse_iot/commands.py

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -44,21 +44,6 @@ def __init__(self, args: argparse.Namespace):
4444
def run(self) -> None:
4545
"""Run the subcommand"""
4646

47-
@property
48-
@abstractmethod
49-
def NAME(self) -> str:
50-
pass
51-
52-
@property
53-
@abstractmethod
54-
def HELP(self) -> str:
55-
pass
56-
57-
@property
58-
@abstractmethod
59-
def DESCRIPTION(self) -> str:
60-
pass
61-
6247

6348
class InfuseRpcCommand:
6449
RPC_DATA_SEND: bool = False

src/infuse_iot/tools/annotate_events.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,6 @@ class TimeCheckType(enum.Enum):
4545

4646

4747
class SubCommand(InfuseCommand):
48-
NAME = "annotate_events"
49-
HELP = "Annotate events on Infuse Tags"
50-
DESCRIPTION = "Save labelled event annotations live on Infuse Tags"
51-
5248
_label_type: LabelType | Path
5349
_labels: list[str]
5450
_time_check: TimeCheckType

src/infuse_iot/tools/audio_record.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,6 @@
2626

2727

2828
class SubCommand(InfuseCommand):
29-
NAME = "audio_record"
30-
HELP = "Record audio data to a file from TDF"
31-
DESCRIPTION = "Record audio data to a file from TDF"
32-
3329
def __init__(self, args):
3430
self._client = LocalClient(args.server_sock, 1.0)
3531
self._decoder = TDF()

src/infuse_iot/tools/auto_activate.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,6 @@
2424

2525

2626
class SubCommand(InfuseCommand):
27-
NAME = "auto_activate"
28-
HELP = "Automatically activate/deactivate observed devices"
29-
DESCRIPTION = "Automatically activate/deactivate observed devices"
30-
3127
def __init__(self, args):
3228
self.app_ids = args.app
3329
self.active = args.active or False

src/infuse_iot/tools/bt_log.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,6 @@
2222

2323

2424
class SubCommand(InfuseCommand):
25-
NAME = "bt_log"
26-
HELP = "Connect to remote Bluetooth device serial logs"
27-
DESCRIPTION = "Connect to remote Bluetooth device serial logs"
28-
2925
def __init__(self, args):
3026
self._client = LocalClient(args.server_sock, 1.0)
3127
self._decoder = TDF()

src/infuse_iot/tools/cloud.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -815,10 +815,6 @@ def upload_diffs_from_application(
815815

816816

817817
class SubCommand(InfuseCommand):
818-
NAME = "cloud"
819-
HELP = "Infuse-IoT cloud interaction"
820-
DESCRIPTION = "Infuse-IoT cloud interaction"
821-
822818
@classmethod
823819
def add_parser(cls, parser):
824820
parser.add_argument("--api-key", type=str, help="Cloud API key to use instead of stored credentials")

src/infuse_iot/tools/credentials.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,11 @@
99

1010
from infuse_iot import credentials
1111
from infuse_iot.commands import InfuseCommand
12+
from infuse_iot.tools.registry import load_extension_tools
1213
from infuse_iot.util.argparse import ValidDir, ValidFile
1314

1415

1516
class SubCommand(InfuseCommand):
16-
NAME = "credentials"
17-
HELP = "Manage Infuse-IoT credentials"
18-
DESCRIPTION = "Manage Infuse-IoT credentials"
19-
2017
@classmethod
2118
def add_parser(cls, parser):
2219
parser.add_argument("--api-key", type=str, help="Set Infuse-IoT API key")
@@ -44,6 +41,7 @@ def run(self):
4441
network_info = yaml.safe_load(content)
4542
credentials.save_network(network_info["id"], content)
4643
if self.args.custom_tools:
44+
load_extension_tools(self.args.custom_tools)
4745
credentials.set_custom_tool_path(str(self.args.custom_tools.absolute()))
4846
if self.args.custom_definitions:
4947
credentials.set_custom_definitions_path(str(self.args.custom_definitions.absolute()))

0 commit comments

Comments
 (0)