benchlab-pytools 3.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- benchlab/__init__.py +14 -0
- benchlab/__main__.py +21 -0
- benchlab/config/README.md +325 -0
- benchlab/config/__init__.py +9 -0
- benchlab/config/config_client.py +631 -0
- benchlab/config/config_manager.py +495 -0
- benchlab/config/config_tool.py +358 -0
- benchlab/config/diff.py +163 -0
- benchlab/config/examples/complete_config.json +56 -0
- benchlab/config/examples/rgb_lighting.json +24 -0
- benchlab/config/examples/simple_fan.json +32 -0
- benchlab/config/schema.py +138 -0
- benchlab/core/README.md +154 -0
- benchlab/core/__init__.py +78 -0
- benchlab/core/config.py +145 -0
- benchlab/core/datasource.py +1209 -0
- benchlab/core/datasource_manager.py +418 -0
- benchlab/core/device_registry.py +206 -0
- benchlab/core/discovery.py +92 -0
- benchlab/core/infrastructure.py +309 -0
- benchlab/core/process_manager.py +356 -0
- benchlab/core/retry.py +85 -0
- benchlab/core/shared_serial.py +235 -0
- benchlab/core/statistics.py +224 -0
- benchlab/csv_log/README.md +206 -0
- benchlab/csv_log/__init__.py +5 -0
- benchlab/csv_log/csv_logger_enhanced.py +416 -0
- benchlab/csv_log/message_batcher.py +361 -0
- benchlab/csv_log/requirements.txt +3 -0
- benchlab/csv_log/smart_retry.py +466 -0
- benchlab/graph/README.md +117 -0
- benchlab/graph/__init__.py +5 -0
- benchlab/graph/app.py +353 -0
- benchlab/graph/requirements.txt +2 -0
- benchlab/graph/runner.py +42 -0
- benchlab/graph/ui.py +288 -0
- benchlab/hwinfo/README.md +135 -0
- benchlab/hwinfo/__init__.py +5 -0
- benchlab/hwinfo/hwinfo_export.py +386 -0
- benchlab/hwinfo/requirements.txt +1 -0
- benchlab/launcher.py +387 -0
- benchlab/link/.env.EXAMPLE +10 -0
- benchlab/link/README.md +172 -0
- benchlab/link/__init__.py +1 -0
- benchlab/link/link_main.py +479 -0
- benchlab/main.py +462 -0
- benchlab/menu.py +729 -0
- benchlab/menu_classic.py +410 -0
- benchlab/mqtt/README.md +201 -0
- benchlab/mqtt/__init__.py +5 -0
- benchlab/mqtt/mqtt.config_template +2 -0
- benchlab/mqtt/mqtt_publisher.py +540 -0
- benchlab/mqtt/requirements.txt +3 -0
- benchlab/prefs.py +67 -0
- benchlab/restapi/.env.example +17 -0
- benchlab/restapi/__init__.py +5 -0
- benchlab/restapi/favicon.ico +0 -0
- benchlab/restapi/readme.md +202 -0
- benchlab/restapi/requirements.txt +5 -0
- benchlab/restapi/telemetry_api.py +808 -0
- benchlab/sources.py +515 -0
- benchlab/tools.py +147 -0
- benchlab/tui/README.md +266 -0
- benchlab/tui/__init__.py +5 -0
- benchlab/tui/config.py +477 -0
- benchlab/tui/requirements.txt +2 -0
- benchlab/tui/tui_core.py +1298 -0
- benchlab/tui/tui_main.py +418 -0
- benchlab/vu/README.md +64 -0
- benchlab/vu/VU-Server/NOTICE.md +13 -0
- benchlab/vu/VU-Server/README.md +80 -0
- benchlab/vu/VU-Server/Running_from_source_on_linux.md +97 -0
- benchlab/vu/VU-Server/__init__.py +0 -0
- benchlab/vu/VU-Server/assets/vu1_hello_world.png +0 -0
- benchlab/vu/VU-Server/community_applications.md +13 -0
- benchlab/vu/VU-Server/config.yaml +9 -0
- benchlab/vu/VU-Server/database.py +256 -0
- benchlab/vu/VU-Server/dial_driver.py +534 -0
- benchlab/vu/VU-Server/dials/Comms_Hub_Gauge.py +78 -0
- benchlab/vu/VU-Server/dials/Comms_Hub_Server.py +100 -0
- benchlab/vu/VU-Server/dials/__init__.py +0 -0
- benchlab/vu/VU-Server/dials/base_logger.py +100 -0
- benchlab/vu/VU-Server/installer/inc/icon.ico +0 -0
- benchlab/vu/VU-Server/installer/install.nsi +148 -0
- benchlab/vu/VU-Server/make_version.py +40 -0
- benchlab/vu/VU-Server/pylintrc +628 -0
- benchlab/vu/VU-Server/requirements.txt +9 -0
- benchlab/vu/VU-Server/serial_driver.py +244 -0
- benchlab/vu/VU-Server/server.py +670 -0
- benchlab/vu/VU-Server/server_config.py +255 -0
- benchlab/vu/VU-Server/server_dial_handler.py +334 -0
- benchlab/vu/VU-Server/vu_notifications.py +24 -0
- benchlab/vu/VU-Server/www/android-chrome-192x192.png +0 -0
- benchlab/vu/VU-Server/www/android-chrome-512x512.png +0 -0
- benchlab/vu/VU-Server/www/apple-touch-icon.png +0 -0
- benchlab/vu/VU-Server/www/assets/css/demo.css +291 -0
- benchlab/vu/VU-Server/www/assets/css/demo.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/demo.rtl.css +276 -0
- benchlab/vu/VU-Server/www/assets/css/demo.rtl.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-flags.css +1084 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-flags.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-flags.rtl.css +1084 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-flags.rtl.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-payments.css +528 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-payments.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-payments.rtl.css +528 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-payments.rtl.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-social.css +10 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-social.min.css +8 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-social.rtl.css +10 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-social.rtl.min.css +8 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-vendors.css +1504 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-vendors.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-vendors.rtl.css +1504 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-vendors.rtl.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/tabler.css +25813 -0
- benchlab/vu/VU-Server/www/assets/css/tabler.min.css +13 -0
- benchlab/vu/VU-Server/www/assets/css/tabler.rtl.css +25777 -0
- benchlab/vu/VU-Server/www/assets/css/tabler.rtl.min.css +13 -0
- benchlab/vu/VU-Server/www/assets/css/theme.min.css +261 -0
- benchlab/vu/VU-Server/www/assets/img/header.png +0 -0
- benchlab/vu/VU-Server/www/assets/img/logo.png +0 -0
- benchlab/vu/VU-Server/www/assets/js/dark-theme.js +35 -0
- benchlab/vu/VU-Server/www/assets/js/dark-theme.min.js +9 -0
- benchlab/vu/VU-Server/www/assets/js/demo.js +132 -0
- benchlab/vu/VU-Server/www/assets/js/demo.min.js +9 -0
- benchlab/vu/VU-Server/www/assets/js/jquery.min.js +2 -0
- benchlab/vu/VU-Server/www/assets/js/tabler.esm.js +7482 -0
- benchlab/vu/VU-Server/www/assets/js/tabler.esm.min.js +15 -0
- benchlab/vu/VU-Server/www/assets/js/tabler.js +7543 -0
- benchlab/vu/VU-Server/www/assets/js/tabler.min.js +15 -0
- benchlab/vu/VU-Server/www/assets/js/vu1_api.js +68 -0
- benchlab/vu/VU-Server/www/assets/js/vu1_gui_api_key_settings.js +167 -0
- benchlab/vu/VU-Server/www/assets/js/vu1_gui_api_keys.js +138 -0
- benchlab/vu/VU-Server/www/assets/js/vu1_gui_dial.js +179 -0
- benchlab/vu/VU-Server/www/assets/js/vu1_gui_home.js +60 -0
- benchlab/vu/VU-Server/www/assets/js/vu1_gui_root.js +125 -0
- benchlab/vu/VU-Server/www/favicon-16x16.png +0 -0
- benchlab/vu/VU-Server/www/favicon-32x32.png +0 -0
- benchlab/vu/VU-Server/www/favicon.ico +0 -0
- benchlab/vu/VU-Server/www/favicon.png +0 -0
- benchlab/vu/VU-Server/www/index.html +47 -0
- benchlab/vu/VU-Server/www/views/api_keys.html +151 -0
- benchlab/vu/VU-Server/www/views/dial.html +286 -0
- benchlab/vu/VU-Server/www/views/footer.html +27 -0
- benchlab/vu/VU-Server/www/views/header.html +86 -0
- benchlab/vu/VU-Server/www/views/key_settings.html +132 -0
- benchlab/vu/VU-Server/www/views/start.html +124 -0
- benchlab/vu/__init__.py +5 -0
- benchlab/vu/assets/Barlow-Bold.ttf +0 -0
- benchlab/vu/assets/bl_dial_144x200.png +0 -0
- benchlab/vu/assets/bl_logo_144x200.png +0 -0
- benchlab/vu/devices.py +166 -0
- benchlab/vu/requirements.txt +24 -0
- benchlab/vu/sensors.py +18 -0
- benchlab/vu/vu_dial.config_template +90 -0
- benchlab/vu/vu_logo_gen.py +97 -0
- benchlab/vu/vu_server.config_template +5 -0
- benchlab/vu/vu_server_config.py +40 -0
- benchlab/vu/vu_server_manager.py +192 -0
- benchlab/vu/vu_tui.py +919 -0
- benchlab/vu/vu_updater.py +540 -0
- benchlab/wigidash/README.md +182 -0
- benchlab/wigidash/__init__.py +5 -0
- benchlab/wigidash/assets/Inter-Bold.ttf +0 -0
- benchlab/wigidash/assets/Inter.ttf +0 -0
- benchlab/wigidash/assets/benchlab.png +0 -0
- benchlab/wigidash/benchlab_fleet.py +150 -0
- benchlab/wigidash/benchlab_graph.py +517 -0
- benchlab/wigidash/benchlab_overview.py +426 -0
- benchlab/wigidash/benchlab_telemetry.py +105 -0
- benchlab/wigidash/benchlab_ui.py +190 -0
- benchlab/wigidash/benchlab_utils.py +182 -0
- benchlab/wigidash/requirements.txt +9 -0
- benchlab/wigidash/wigidash_device.py +541 -0
- benchlab/wigidash/wigidash_manager.py +342 -0
- benchlab/wigidash/wigidash_session.py +355 -0
- benchlab/wigidash/wigidash_usb.py +247 -0
- benchlab/wigidash/wigidash_widget.py +88 -0
- benchlab_pytools-3.0.0.dist-info/METADATA +239 -0
- benchlab_pytools-3.0.0.dist-info/RECORD +184 -0
- benchlab_pytools-3.0.0.dist-info/WHEEL +4 -0
- benchlab_pytools-3.0.0.dist-info/entry_points.txt +2 -0
- benchlab_pytools-3.0.0.dist-info/licenses/LICENSE +21 -0
benchlab/menu.py
ADDED
|
@@ -0,0 +1,729 @@
|
|
|
1
|
+
"""BENCHLAB PyTools v2 – Interactive Menu System (Textual).
|
|
2
|
+
|
|
3
|
+
A single full-screen TUI, replacing the old numbered-prompt flow (still
|
|
4
|
+
available as menu_classic.py, used as an automatic fallback if textual
|
|
5
|
+
isn't installed or the app can't start in the current terminal).
|
|
6
|
+
|
|
7
|
+
Flow:
|
|
8
|
+
1. Launching straight into the picker screen -- no separate top-level
|
|
9
|
+
"what would you like to do" prompt first.
|
|
10
|
+
2. Two tabs: "Run Tool(s)" (Tools checklist stacked above a Data Source
|
|
11
|
+
radio list, live-filtered to what the selected tool(s) support) and
|
|
12
|
+
"Data Provider" (run FastAPI or MQTT standalone, no consumer tool).
|
|
13
|
+
3. Last-used tool(s)/source are pre-selected on open.
|
|
14
|
+
4. Launch/Start, then remember the choice for next time (tools mode only).
|
|
15
|
+
5. Any source-specific connection params (broker host, API port, etc.)
|
|
16
|
+
are prompted for afterward in the terminal, pre-filled from last use
|
|
17
|
+
-- kept out of the TUI screen itself to avoid duplicating input
|
|
18
|
+
validation in two places.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
import os
|
|
23
|
+
import sys
|
|
24
|
+
from typing import Dict, List, Optional, Tuple, Union
|
|
25
|
+
|
|
26
|
+
from bootstrap import clear_screen
|
|
27
|
+
from .tools import CONSUMER_TOOLS
|
|
28
|
+
from .sources import (
|
|
29
|
+
check_and_setup_source,
|
|
30
|
+
check_mqtt_running,
|
|
31
|
+
start_mqtt_broker,
|
|
32
|
+
start_mqtt_source,
|
|
33
|
+
cleanup_all_services,
|
|
34
|
+
SERVICE_HTTP_DEFAULT_PORT,
|
|
35
|
+
)
|
|
36
|
+
from .launcher import launch_single_tool, launch_tools_concurrent
|
|
37
|
+
from .prefs import load_prefs, record_launch, get_source_params
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger("benchlab.launcher")
|
|
40
|
+
|
|
41
|
+
BANNER = r"""
|
|
42
|
+
██████╗ ███████╗███╗ ██╗ ██████╗██╗ ██╗██╗ █████╗ ██████╗
|
|
43
|
+
██╔══██╗██╔════╝████╗ ██║██╔════╝██║ ██║██║ ██╔══██╗██╔══██╗
|
|
44
|
+
██████╔╝█████╗ ██╔██╗ ██║██║ ███████║██║ ███████║██████╔╝
|
|
45
|
+
██╔══██╗██╔══╝ ██║╚██╗██║██║ ██╔══██║██║ ██╔══██║██╔══██╗
|
|
46
|
+
██████╔╝███████╗██║ ╚████║╚██████╗██║ ██║███████╗██║ ██║██████╔╝
|
|
47
|
+
╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═════╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═════╝
|
|
48
|
+
|
|
49
|
+
██████╗ ██╗ ██╗████████╗ ██████╗ ██████╗ ██╗ ███████╗
|
|
50
|
+
██╔══██╗╚██╗ ██╔╝╚══██╔══╝██╔═══██╗██╔═══██╗██║ ██╔════╝
|
|
51
|
+
██████╔╝ ╚████╔╝ ██║ ██║ ██║██║ ██║██║ ███████╗
|
|
52
|
+
██╔═══╝ ╚██╔╝ ██║ ██║ ██║██║ ██║██║ ╚════██║
|
|
53
|
+
██║ ██║ ██║ ╚██████╔╝╚██████╔╝███████╗███████║
|
|
54
|
+
╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝╚══════╝
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
SOURCE_LABELS: Dict[str, str] = {
|
|
58
|
+
"direct": "Direct (serial port)",
|
|
59
|
+
"fastapi": "FastAPI server (Python)",
|
|
60
|
+
"fastapi_custom": "FastAPI server (custom URL)",
|
|
61
|
+
"mqtt": "MQTT (Python, experimental)",
|
|
62
|
+
"mqtt_custom": "MQTT (custom)",
|
|
63
|
+
"named_pipe": "BenchLab service - named pipe",
|
|
64
|
+
"service_http": (
|
|
65
|
+
f"BenchLab service - HTTP API (port {SERVICE_HTTP_DEFAULT_PORT})"
|
|
66
|
+
),
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
SOURCE_ORDER = [
|
|
70
|
+
"direct",
|
|
71
|
+
"fastapi",
|
|
72
|
+
"fastapi_custom",
|
|
73
|
+
"mqtt",
|
|
74
|
+
"mqtt_custom",
|
|
75
|
+
"named_pipe",
|
|
76
|
+
"service_http"]
|
|
77
|
+
|
|
78
|
+
PROVIDER_LABELS: Dict[str, str] = {
|
|
79
|
+
"fastapi": "FastAPI Server - REST API + WebSocket on port 8000",
|
|
80
|
+
"mqtt": "MQTT Publisher - publish telemetry to MQTT broker",
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def print_banner() -> None:
|
|
85
|
+
print(BANNER)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ──────────────────────────────────────────────────────────────
|
|
89
|
+
# Source compatibility (pure logic, reused by the picker and tests)
|
|
90
|
+
# ──────────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
def _available_sources(
|
|
93
|
+
is_multi: bool,
|
|
94
|
+
supported_sources: Optional[List[str]]) -> List[Tuple[str, str]]:
|
|
95
|
+
"""Return (source_id, label) pairs valid for the given tool selection."""
|
|
96
|
+
is_windows = sys.platform.startswith("win")
|
|
97
|
+
|
|
98
|
+
result = []
|
|
99
|
+
for src in SOURCE_ORDER:
|
|
100
|
+
if src == "direct" and is_multi:
|
|
101
|
+
continue
|
|
102
|
+
if src == "named_pipe" and not is_windows:
|
|
103
|
+
continue
|
|
104
|
+
if supported_sources is not None and src not in supported_sources:
|
|
105
|
+
continue
|
|
106
|
+
result.append((src, SOURCE_LABELS[src]))
|
|
107
|
+
return result
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _combined_supported_sources(tool_ids: List[str]) -> Optional[List[str]]:
|
|
111
|
+
"""Intersection of supported_sources across selected tools.
|
|
112
|
+
|
|
113
|
+
None means "no restriction" (tool doesn't declare supported_sources,
|
|
114
|
+
or no tools selected yet).
|
|
115
|
+
"""
|
|
116
|
+
restrictions = [
|
|
117
|
+
CONSUMER_TOOLS[tid]["supported_sources"]
|
|
118
|
+
for tid in tool_ids
|
|
119
|
+
if tid in CONSUMER_TOOLS
|
|
120
|
+
and CONSUMER_TOOLS[tid].get("supported_sources") is not None
|
|
121
|
+
]
|
|
122
|
+
if not restrictions:
|
|
123
|
+
return None
|
|
124
|
+
combined = set(restrictions[0])
|
|
125
|
+
for r in restrictions[1:]:
|
|
126
|
+
combined &= set(r)
|
|
127
|
+
return list(combined)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ──────────────────────────────────────────────────────────────
|
|
131
|
+
# Textual app: "Run Tool(s)" tab + "Data Provider" tab
|
|
132
|
+
# ──────────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
# Result shape returned by the app / _run_picker_screen:
|
|
135
|
+
# ("tools", tool_ids, source) -- launch consumer tool(s)
|
|
136
|
+
# ("provider", provider_type) -- start a standalone data provider
|
|
137
|
+
# None -- cancelled
|
|
138
|
+
PickerResult = Union[Tuple[str, List[str], str], Tuple[str, str], None]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _build_launcher_app(
|
|
142
|
+
default_tool_ids: List[str],
|
|
143
|
+
default_source: Optional[str]):
|
|
144
|
+
"""Construct (but don't run) the Textual LauncherApp. Split out from
|
|
145
|
+
_run_picker_screen so tests can inspect app state without a real
|
|
146
|
+
terminal (Textual apps can run headless via App.run_test())."""
|
|
147
|
+
from textual.app import App, ComposeResult
|
|
148
|
+
from textual.containers import Vertical
|
|
149
|
+
from textual.widgets import (
|
|
150
|
+
Footer,
|
|
151
|
+
SelectionList,
|
|
152
|
+
RadioSet,
|
|
153
|
+
RadioButton,
|
|
154
|
+
Button,
|
|
155
|
+
Static,
|
|
156
|
+
TabbedContent,
|
|
157
|
+
TabPane,
|
|
158
|
+
)
|
|
159
|
+
from textual.widgets.selection_list import Selection
|
|
160
|
+
from textual.binding import Binding
|
|
161
|
+
|
|
162
|
+
class LauncherApp(App):
|
|
163
|
+
CSS = """
|
|
164
|
+
Screen {
|
|
165
|
+
background: $surface;
|
|
166
|
+
}
|
|
167
|
+
#banner {
|
|
168
|
+
width: 100%;
|
|
169
|
+
content-align: center middle;
|
|
170
|
+
color: $accent;
|
|
171
|
+
margin-bottom: 1;
|
|
172
|
+
}
|
|
173
|
+
.panel {
|
|
174
|
+
border: round $primary;
|
|
175
|
+
padding: 0 1;
|
|
176
|
+
margin-bottom: 1;
|
|
177
|
+
}
|
|
178
|
+
.panel:focus-within {
|
|
179
|
+
border: round $accent;
|
|
180
|
+
}
|
|
181
|
+
#tools-panel {
|
|
182
|
+
height: 1fr;
|
|
183
|
+
}
|
|
184
|
+
#source-panel {
|
|
185
|
+
height: auto;
|
|
186
|
+
max-height: 40%;
|
|
187
|
+
}
|
|
188
|
+
#provider-panel {
|
|
189
|
+
height: auto;
|
|
190
|
+
}
|
|
191
|
+
#tools-summary, #provider-summary {
|
|
192
|
+
height: auto;
|
|
193
|
+
margin-bottom: 1;
|
|
194
|
+
color: $text-muted;
|
|
195
|
+
}
|
|
196
|
+
.launch-btn {
|
|
197
|
+
width: 100%;
|
|
198
|
+
margin-top: 1;
|
|
199
|
+
}
|
|
200
|
+
.launch-btn.-ready {
|
|
201
|
+
background: $success;
|
|
202
|
+
}
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
BINDINGS = [
|
|
206
|
+
Binding("q", "quit_app", "Quit"),
|
|
207
|
+
Binding("escape", "quit_app", "Quit"),
|
|
208
|
+
Binding("ctrl+l", "launch", "Launch"),
|
|
209
|
+
]
|
|
210
|
+
|
|
211
|
+
TITLE = "BENCHLAB PyTools"
|
|
212
|
+
|
|
213
|
+
def __init__(self):
|
|
214
|
+
super().__init__()
|
|
215
|
+
self.result: PickerResult = None
|
|
216
|
+
self._default_tool_ids = default_tool_ids
|
|
217
|
+
self._default_source = default_source
|
|
218
|
+
|
|
219
|
+
def compose(self) -> ComposeResult:
|
|
220
|
+
yield Static(BANNER, id="banner")
|
|
221
|
+
with TabbedContent(id="mode-tabs"):
|
|
222
|
+
with TabPane("Run Tool(s)", id="tab-tools"):
|
|
223
|
+
with Vertical(id="tools-panel", classes="panel"):
|
|
224
|
+
yield Static("[b]Tools[/b] (space to toggle)")
|
|
225
|
+
tool_selections = [
|
|
226
|
+
Selection(
|
|
227
|
+
f"{t['name']} - {t['description']}",
|
|
228
|
+
tid,
|
|
229
|
+
tid in self._default_tool_ids)
|
|
230
|
+
for tid, t in CONSUMER_TOOLS.items()
|
|
231
|
+
]
|
|
232
|
+
yield SelectionList[str](*tool_selections, id="tools")
|
|
233
|
+
with Vertical(id="source-panel", classes="panel"):
|
|
234
|
+
yield Static("[b]Data Source[/b]")
|
|
235
|
+
yield RadioSet(id="sources")
|
|
236
|
+
yield Static("", id="tools-summary")
|
|
237
|
+
yield Button(
|
|
238
|
+
"Launch", id="launch-btn", variant="primary",
|
|
239
|
+
classes="launch-btn")
|
|
240
|
+
with TabPane("Data Provider", id="tab-provider"):
|
|
241
|
+
with Vertical(classes="panel", id="provider-panel"):
|
|
242
|
+
yield Static(
|
|
243
|
+
"[b]Provider[/b] - runs standalone, "
|
|
244
|
+
"no consumer tool")
|
|
245
|
+
yield RadioSet(
|
|
246
|
+
RadioButton(
|
|
247
|
+
PROVIDER_LABELS["fastapi"], value=True),
|
|
248
|
+
RadioButton(PROVIDER_LABELS["mqtt"]),
|
|
249
|
+
id="providers",
|
|
250
|
+
)
|
|
251
|
+
yield Static("", id="provider-summary")
|
|
252
|
+
yield Button(
|
|
253
|
+
"Start Provider", id="provider-btn",
|
|
254
|
+
variant="primary", classes="launch-btn")
|
|
255
|
+
yield Footer()
|
|
256
|
+
|
|
257
|
+
async def on_mount(self) -> None:
|
|
258
|
+
await self._refresh_sources()
|
|
259
|
+
self._update_provider_summary()
|
|
260
|
+
|
|
261
|
+
# ── Tools tab ────────────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
def _selected_tool_ids(self) -> List[str]:
|
|
264
|
+
return list(self.query_one("#tools", SelectionList).selected)
|
|
265
|
+
|
|
266
|
+
def _current_source_items(self) -> List[Tuple[str, str]]:
|
|
267
|
+
tool_ids = self._selected_tool_ids()
|
|
268
|
+
supported = _combined_supported_sources(tool_ids)
|
|
269
|
+
sources = _available_sources(
|
|
270
|
+
is_multi=len(tool_ids) > 1,
|
|
271
|
+
supported_sources=supported)
|
|
272
|
+
return sources
|
|
273
|
+
|
|
274
|
+
async def _refresh_sources(self) -> None:
|
|
275
|
+
sources = self._current_source_items()
|
|
276
|
+
radio_set = self.query_one("#sources", RadioSet)
|
|
277
|
+
self._source_ids = [s for s, _ in sources]
|
|
278
|
+
|
|
279
|
+
radio_set._pressed_button = None
|
|
280
|
+
await radio_set.remove_children()
|
|
281
|
+
if not sources:
|
|
282
|
+
await radio_set.mount(
|
|
283
|
+
RadioButton("(no compatible source)", disabled=True))
|
|
284
|
+
else:
|
|
285
|
+
preferred_idx = 0
|
|
286
|
+
if self._default_source in self._source_ids:
|
|
287
|
+
preferred_idx = self._source_ids.index(
|
|
288
|
+
self._default_source)
|
|
289
|
+
buttons = [RadioButton(label) for _, label in sources]
|
|
290
|
+
await radio_set.mount(*buttons)
|
|
291
|
+
# RadioSet only auto-selects a pre-checked button in its own
|
|
292
|
+
# one-time _on_mount scan; that doesn't re-run on a dynamic
|
|
293
|
+
# remount, and setting .value=True just posts an async
|
|
294
|
+
# Changed message we'd have to await-settle before reading
|
|
295
|
+
# pressed_index. Simpler and immediate: set the internal
|
|
296
|
+
# pointer directly, matching what _on_mount itself does.
|
|
297
|
+
with radio_set.prevent(RadioButton.Changed):
|
|
298
|
+
buttons[preferred_idx].value = True
|
|
299
|
+
radio_set._pressed_button = buttons[preferred_idx]
|
|
300
|
+
self._update_tools_summary()
|
|
301
|
+
|
|
302
|
+
def _current_source_id(self) -> Optional[str]:
|
|
303
|
+
radio_set = self.query_one("#sources", RadioSet)
|
|
304
|
+
idx = radio_set.pressed_index
|
|
305
|
+
if idx is None or idx < 0 or not getattr(
|
|
306
|
+
self, "_source_ids", None):
|
|
307
|
+
return None
|
|
308
|
+
if idx >= len(self._source_ids):
|
|
309
|
+
return None
|
|
310
|
+
return self._source_ids[idx]
|
|
311
|
+
|
|
312
|
+
def _update_tools_summary(self) -> None:
|
|
313
|
+
tool_ids = self._selected_tool_ids()
|
|
314
|
+
source = self._current_source_id()
|
|
315
|
+
tool_names = ", ".join(
|
|
316
|
+
CONSUMER_TOOLS[tid]["name"]
|
|
317
|
+
for tid in tool_ids) or "(none selected)"
|
|
318
|
+
source_label = SOURCE_LABELS.get(
|
|
319
|
+
source, "(none)") if source else "(none)"
|
|
320
|
+
summary = self.query_one("#tools-summary", Static)
|
|
321
|
+
summary.update(f"Tools: {tool_names} | Source: {source_label}")
|
|
322
|
+
|
|
323
|
+
btn = self.query_one("#launch-btn", Button)
|
|
324
|
+
ready = bool(tool_ids and source)
|
|
325
|
+
btn.disabled = not ready
|
|
326
|
+
btn.set_class(ready, "-ready")
|
|
327
|
+
|
|
328
|
+
async def on_selection_list_selected_changed(self, event) -> None:
|
|
329
|
+
if event.selection_list.id == "tools":
|
|
330
|
+
await self._refresh_sources()
|
|
331
|
+
|
|
332
|
+
def on_radio_set_changed(self, event) -> None:
|
|
333
|
+
if event.radio_set.id == "sources":
|
|
334
|
+
self._update_tools_summary()
|
|
335
|
+
elif event.radio_set.id == "providers":
|
|
336
|
+
self._update_provider_summary()
|
|
337
|
+
|
|
338
|
+
def on_button_pressed(self, event) -> None:
|
|
339
|
+
if event.button.id == "launch-btn":
|
|
340
|
+
self.action_launch()
|
|
341
|
+
elif event.button.id == "provider-btn":
|
|
342
|
+
self._start_provider()
|
|
343
|
+
|
|
344
|
+
def action_launch(self) -> None:
|
|
345
|
+
tool_ids = self._selected_tool_ids()
|
|
346
|
+
source = self._current_source_id()
|
|
347
|
+
if tool_ids and source:
|
|
348
|
+
self.result = ("tools", tool_ids, source)
|
|
349
|
+
self.exit()
|
|
350
|
+
|
|
351
|
+
# ── Data Provider tab ───────────────────────────────────
|
|
352
|
+
|
|
353
|
+
def _current_provider(self) -> str:
|
|
354
|
+
idx = self.query_one("#providers", RadioSet).pressed_index
|
|
355
|
+
return "mqtt" if idx == 1 else "fastapi"
|
|
356
|
+
|
|
357
|
+
def _update_provider_summary(self) -> None:
|
|
358
|
+
provider = self._current_provider()
|
|
359
|
+
summary = self.query_one("#provider-summary", Static)
|
|
360
|
+
summary.update(f"Provider: {PROVIDER_LABELS[provider]}")
|
|
361
|
+
|
|
362
|
+
def _start_provider(self) -> None:
|
|
363
|
+
self.result = ("provider", self._current_provider())
|
|
364
|
+
self.exit()
|
|
365
|
+
|
|
366
|
+
# ── Global actions ──────────────────────────────────────
|
|
367
|
+
|
|
368
|
+
def action_quit_app(self) -> None:
|
|
369
|
+
self.result = None
|
|
370
|
+
self.exit()
|
|
371
|
+
|
|
372
|
+
return LauncherApp()
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _run_picker_screen(
|
|
376
|
+
default_tool_ids: List[str],
|
|
377
|
+
default_source: Optional[str]) -> PickerResult:
|
|
378
|
+
"""Run the full-screen picker. Returns a PickerResult (see above) or
|
|
379
|
+
None if cancelled.
|
|
380
|
+
|
|
381
|
+
Falls back to the sequential prompt-based picker (_sequential_pick) if
|
|
382
|
+
Textual's app can't start in this terminal.
|
|
383
|
+
"""
|
|
384
|
+
app = _build_launcher_app(default_tool_ids, default_source)
|
|
385
|
+
try:
|
|
386
|
+
app.run()
|
|
387
|
+
except Exception as e:
|
|
388
|
+
logger.debug(
|
|
389
|
+
f"Textual picker failed ({e}); "
|
|
390
|
+
"falling back to sequential prompts")
|
|
391
|
+
return _sequential_pick(default_tool_ids, default_source)
|
|
392
|
+
except KeyboardInterrupt:
|
|
393
|
+
return None
|
|
394
|
+
return app.result
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
# ──────────────────────────────────────────────────────────────
|
|
398
|
+
# Sequential fallback (used if the full-screen app can't run)
|
|
399
|
+
# ──────────────────────────────────────────────────────────────
|
|
400
|
+
|
|
401
|
+
def _fuzzy_pick(items: List[Tuple[str, str]], title: str,
|
|
402
|
+
default: Optional[str] = None) -> Optional[str]:
|
|
403
|
+
"""Single-select picker over (value, label) pairs, printing the full
|
|
404
|
+
option list up front. Accepts a number or enough of a label to match
|
|
405
|
+
exactly one option.
|
|
406
|
+
"""
|
|
407
|
+
default_index = next(
|
|
408
|
+
(i for i, (value, _) in enumerate(
|
|
409
|
+
items, 1) if value == default), None)
|
|
410
|
+
|
|
411
|
+
print(f"\n{title}")
|
|
412
|
+
for i, (_, label) in enumerate(items, 1):
|
|
413
|
+
marker = " (last used)" if i == default_index else ""
|
|
414
|
+
print(f" {i}. {label}{marker}")
|
|
415
|
+
default_str = str(default_index) if default_index else ""
|
|
416
|
+
suffix = f" [{default_str}]" if default_str else ""
|
|
417
|
+
try:
|
|
418
|
+
answer = input(f"Choice{suffix}: ").strip() or default_str
|
|
419
|
+
except (EOFError, KeyboardInterrupt):
|
|
420
|
+
return None
|
|
421
|
+
|
|
422
|
+
if not answer:
|
|
423
|
+
return None
|
|
424
|
+
|
|
425
|
+
if answer.isdigit():
|
|
426
|
+
idx = int(answer)
|
|
427
|
+
if 1 <= idx <= len(items):
|
|
428
|
+
return items[idx - 1][0]
|
|
429
|
+
print(f" No option numbered {idx}.")
|
|
430
|
+
return None
|
|
431
|
+
|
|
432
|
+
labels = [label for _, label in items]
|
|
433
|
+
matches = [label for label in labels if answer.lower() in label.lower()]
|
|
434
|
+
if len(matches) == 1:
|
|
435
|
+
idx = labels.index(matches[0])
|
|
436
|
+
return items[idx][0]
|
|
437
|
+
|
|
438
|
+
print(f" No unambiguous match for {answer!r}.")
|
|
439
|
+
return None
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _multi_pick(items: List[Tuple[str, str]], title: str) -> List[str]:
|
|
443
|
+
"""Comma-separated number list multi-select (plain input())."""
|
|
444
|
+
print(f"\n{title}")
|
|
445
|
+
for i, (_, label) in enumerate(items, 1):
|
|
446
|
+
print(f" {i}. {label}")
|
|
447
|
+
print(" Enter numbers separated by commas (e.g. 1,3), or 'all'.")
|
|
448
|
+
try:
|
|
449
|
+
answer = input("> ").strip().lower()
|
|
450
|
+
except (EOFError, KeyboardInterrupt):
|
|
451
|
+
return []
|
|
452
|
+
if not answer:
|
|
453
|
+
return []
|
|
454
|
+
if answer == "all":
|
|
455
|
+
return [value for value, _ in items]
|
|
456
|
+
selected = []
|
|
457
|
+
for part in answer.split(","):
|
|
458
|
+
part = part.strip()
|
|
459
|
+
if part.isdigit() and 1 <= int(part) <= len(items):
|
|
460
|
+
selected.append(items[int(part) - 1][0])
|
|
461
|
+
return selected
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _sequential_pick(
|
|
465
|
+
default_tool_ids: List[str],
|
|
466
|
+
default_source: Optional[str]) -> PickerResult:
|
|
467
|
+
"""Fallback flow when the full-screen picker can't run: choose tools
|
|
468
|
+
vs. data-provider mode, then pick accordingly, via sequential prompts."""
|
|
469
|
+
mode_choice = _fuzzy_pick(
|
|
470
|
+
[
|
|
471
|
+
(
|
|
472
|
+
"tools",
|
|
473
|
+
"Run tool(s) - pick one or more consumer tools and a "
|
|
474
|
+
"data source",
|
|
475
|
+
),
|
|
476
|
+
(
|
|
477
|
+
"provider",
|
|
478
|
+
"Data provider - run FastAPI or MQTT standalone for "
|
|
479
|
+
"other processes",
|
|
480
|
+
),
|
|
481
|
+
],
|
|
482
|
+
"What would you like to do?",
|
|
483
|
+
)
|
|
484
|
+
if mode_choice is None:
|
|
485
|
+
return None
|
|
486
|
+
|
|
487
|
+
if mode_choice == "provider":
|
|
488
|
+
provider = _fuzzy_pick(
|
|
489
|
+
list(
|
|
490
|
+
PROVIDER_LABELS.items()),
|
|
491
|
+
"Select a data provider:")
|
|
492
|
+
if provider is None:
|
|
493
|
+
return None
|
|
494
|
+
return ("provider", provider)
|
|
495
|
+
|
|
496
|
+
tool_values = [(tid, f"{t['name']} - {t['description']}")
|
|
497
|
+
for tid, t in CONSUMER_TOOLS.items()]
|
|
498
|
+
|
|
499
|
+
print("\n=== Select Tool(s) ===")
|
|
500
|
+
try:
|
|
501
|
+
sub_mode = input(
|
|
502
|
+
"Single tool or multiple? (s/m) [s]: ").strip().lower() or "s"
|
|
503
|
+
except (EOFError, KeyboardInterrupt):
|
|
504
|
+
return None
|
|
505
|
+
|
|
506
|
+
if sub_mode.startswith("m"):
|
|
507
|
+
tool_ids = _multi_pick(tool_values, "Select Tools")
|
|
508
|
+
if not tool_ids:
|
|
509
|
+
print(" No tools selected.")
|
|
510
|
+
return None
|
|
511
|
+
else:
|
|
512
|
+
tool_id = _fuzzy_pick(
|
|
513
|
+
tool_values,
|
|
514
|
+
"Select a tool:",
|
|
515
|
+
default=default_tool_ids[0] if default_tool_ids else None)
|
|
516
|
+
if tool_id is None:
|
|
517
|
+
return None
|
|
518
|
+
tool_ids = [tool_id]
|
|
519
|
+
|
|
520
|
+
supported = _combined_supported_sources(tool_ids)
|
|
521
|
+
sources = _available_sources(
|
|
522
|
+
is_multi=len(tool_ids) > 1,
|
|
523
|
+
supported_sources=supported)
|
|
524
|
+
if not sources:
|
|
525
|
+
print(" No compatible data source available for this selection.")
|
|
526
|
+
return None
|
|
527
|
+
|
|
528
|
+
valid_defaults = [s for s, _ in sources]
|
|
529
|
+
default = default_source if default_source in valid_defaults else None
|
|
530
|
+
source = _fuzzy_pick(sources, "Select a data source:", default=default)
|
|
531
|
+
if source is None:
|
|
532
|
+
return None
|
|
533
|
+
|
|
534
|
+
return ("tools", tool_ids, source)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
# ──────────────────────────────────────────────────────────────
|
|
538
|
+
# Source connection params (pre-filled from remembered prefs)
|
|
539
|
+
# ──────────────────────────────────────────────────────────────
|
|
540
|
+
|
|
541
|
+
def _prompt_default(label: str, default: str) -> str:
|
|
542
|
+
try:
|
|
543
|
+
val = input(f" {label} [{default}]: ").strip()
|
|
544
|
+
except (EOFError, KeyboardInterrupt):
|
|
545
|
+
return default
|
|
546
|
+
return val or default
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
_last_source_params: Dict[str, Dict] = {}
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _setup_source(source_type: str) -> bool:
|
|
553
|
+
"""Prompt for any source-specific params, then check/start the source."""
|
|
554
|
+
remembered = get_source_params(source_type)
|
|
555
|
+
setup_kwargs: Dict = {}
|
|
556
|
+
|
|
557
|
+
if source_type == "fastapi":
|
|
558
|
+
port = int(remembered.get("port", os.environ.get("API_PORT", "8000")))
|
|
559
|
+
setup_kwargs = {"port": port}
|
|
560
|
+
|
|
561
|
+
elif source_type == "fastapi_custom":
|
|
562
|
+
host = _prompt_default("Host/IP", remembered.get("host", "127.0.0.1"))
|
|
563
|
+
port = _prompt_default("Port", str(remembered.get("port", 8000)))
|
|
564
|
+
try:
|
|
565
|
+
port = int(port)
|
|
566
|
+
except ValueError:
|
|
567
|
+
print(" Invalid port number.")
|
|
568
|
+
return False
|
|
569
|
+
base_url = f"http://{host}:{port}"
|
|
570
|
+
setup_kwargs = {"base_url": base_url}
|
|
571
|
+
os.environ["BENCHLAB_FASTAPI_CUSTOM_URL"] = base_url
|
|
572
|
+
remembered = {"host": host, "port": port}
|
|
573
|
+
|
|
574
|
+
elif source_type == "mqtt":
|
|
575
|
+
broker = os.environ.get(
|
|
576
|
+
"MQTT_BROKER", remembered.get(
|
|
577
|
+
"broker", "localhost"))
|
|
578
|
+
mqtt_port = int(
|
|
579
|
+
os.environ.get(
|
|
580
|
+
"MQTT_PORT",
|
|
581
|
+
remembered.get(
|
|
582
|
+
"mqtt_port",
|
|
583
|
+
1883)))
|
|
584
|
+
setup_kwargs = {"broker": broker, "mqtt_port": mqtt_port}
|
|
585
|
+
|
|
586
|
+
elif source_type == "mqtt_custom":
|
|
587
|
+
broker = _prompt_default(
|
|
588
|
+
"Broker host", remembered.get(
|
|
589
|
+
"broker", "localhost"))
|
|
590
|
+
mqtt_port = _prompt_default(
|
|
591
|
+
"Broker port", str(
|
|
592
|
+
remembered.get(
|
|
593
|
+
"mqtt_port", 1883)))
|
|
594
|
+
try:
|
|
595
|
+
mqtt_port = int(mqtt_port)
|
|
596
|
+
except ValueError:
|
|
597
|
+
print(" Invalid port number.")
|
|
598
|
+
return False
|
|
599
|
+
setup_kwargs = {"broker": broker, "mqtt_port": mqtt_port}
|
|
600
|
+
remembered = {"broker": broker, "mqtt_port": mqtt_port}
|
|
601
|
+
|
|
602
|
+
elif source_type == "service_http":
|
|
603
|
+
import urllib.parse
|
|
604
|
+
svc_url = os.environ.get(
|
|
605
|
+
"BENCHLAB_SERVICE_URL",
|
|
606
|
+
f"http://localhost:{SERVICE_HTTP_DEFAULT_PORT}")
|
|
607
|
+
parsed = urllib.parse.urlparse(svc_url)
|
|
608
|
+
setup_kwargs = {"host": parsed.hostname or "localhost",
|
|
609
|
+
"port": parsed.port or SERVICE_HTTP_DEFAULT_PORT}
|
|
610
|
+
|
|
611
|
+
ready = check_and_setup_source(source_type, **setup_kwargs)
|
|
612
|
+
_last_source_params[source_type] = remembered
|
|
613
|
+
return ready
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
# ──────────────────────────────────────────────────────────────
|
|
617
|
+
# Data Provider Mode (standalone FastAPI/MQTT, no consumer tool)
|
|
618
|
+
# ──────────────────────────────────────────────────────────────
|
|
619
|
+
|
|
620
|
+
def _run_data_provider(provider: str) -> None:
|
|
621
|
+
if provider == "fastapi":
|
|
622
|
+
port = _prompt_default("Port", "8000")
|
|
623
|
+
try:
|
|
624
|
+
port = int(port)
|
|
625
|
+
except ValueError:
|
|
626
|
+
print(" Invalid port number.")
|
|
627
|
+
return
|
|
628
|
+
os.environ["API_PORT"] = str(port)
|
|
629
|
+
if not check_and_setup_source("fastapi", port=port):
|
|
630
|
+
logger.error("Could not start FastAPI server.")
|
|
631
|
+
return
|
|
632
|
+
print("FastAPI server running. Press Ctrl+C to stop the provider.")
|
|
633
|
+
input(" (Press Enter to return to menu after verifying...) ")
|
|
634
|
+
|
|
635
|
+
elif provider == "mqtt":
|
|
636
|
+
host = _prompt_default("Broker host", "localhost")
|
|
637
|
+
port = _prompt_default("Broker port", "1883")
|
|
638
|
+
try:
|
|
639
|
+
port = int(port)
|
|
640
|
+
except ValueError:
|
|
641
|
+
print(" Invalid port number.")
|
|
642
|
+
return
|
|
643
|
+
if not check_mqtt_running(host, port):
|
|
644
|
+
logger.warning(f"No MQTT broker at {host}:{port}")
|
|
645
|
+
logger.info("Starting embedded broker...")
|
|
646
|
+
if not start_mqtt_broker(port):
|
|
647
|
+
logger.error("Could not start MQTT broker.")
|
|
648
|
+
return
|
|
649
|
+
else:
|
|
650
|
+
logger.info(f"MQTT broker available at {host}:{port}")
|
|
651
|
+
os.environ["MQTT_BROKER"] = host
|
|
652
|
+
os.environ["MQTT_PORT"] = str(port)
|
|
653
|
+
start_mqtt_source(host, port)
|
|
654
|
+
logger.info("Press Ctrl+C to stop the provider.")
|
|
655
|
+
input(" (Press Enter to return to menu after verifying...) ")
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
# ──────────────────────────────────────────────────────────────
|
|
659
|
+
# Top-level flow
|
|
660
|
+
# ──────────────────────────────────────────────────────────────
|
|
661
|
+
|
|
662
|
+
def _launch(tool_ids: List[str], source: str) -> None:
|
|
663
|
+
tool_names = [CONSUMER_TOOLS[tid]["name"] for tid in tool_ids]
|
|
664
|
+
|
|
665
|
+
_last_source_params.clear()
|
|
666
|
+
if not _setup_source(source):
|
|
667
|
+
print(f"\n Could not set up '{source}' data source.")
|
|
668
|
+
if source in ("named_pipe", "service_http"):
|
|
669
|
+
print(
|
|
670
|
+
" Start the BenchLab Windows service (BL_Service.exe) "
|
|
671
|
+
"and try again.")
|
|
672
|
+
return
|
|
673
|
+
|
|
674
|
+
print("\n=== Launch Summary ===")
|
|
675
|
+
print(f"Tools: {', '.join(tool_names)}")
|
|
676
|
+
print(f"Data source: {source}")
|
|
677
|
+
try:
|
|
678
|
+
if input("Launch? (Y/n): ").strip().lower() in ("n", "no"):
|
|
679
|
+
print("Aborted.")
|
|
680
|
+
return
|
|
681
|
+
except (EOFError, KeyboardInterrupt):
|
|
682
|
+
print("\nAborted.")
|
|
683
|
+
return
|
|
684
|
+
|
|
685
|
+
os.environ["BENCHLAB_DATA_SOURCE"] = source
|
|
686
|
+
record_launch(tool_ids, source, _last_source_params.get(source))
|
|
687
|
+
|
|
688
|
+
try:
|
|
689
|
+
if len(tool_ids) > 1:
|
|
690
|
+
launch_tools_concurrent(tool_ids)
|
|
691
|
+
else:
|
|
692
|
+
launch_single_tool(tool_ids[0])
|
|
693
|
+
except KeyboardInterrupt:
|
|
694
|
+
print("\n Interrupted.")
|
|
695
|
+
finally:
|
|
696
|
+
cleanup_all_services()
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def interactive_loop() -> None:
|
|
700
|
+
"""Drive the interactive menu until the user quits."""
|
|
701
|
+
while True:
|
|
702
|
+
try:
|
|
703
|
+
prefs = load_prefs()
|
|
704
|
+
default_tool_ids = [
|
|
705
|
+
tid for tid in (
|
|
706
|
+
prefs.get("last_tool_ids") or []) if tid in CONSUMER_TOOLS]
|
|
707
|
+
default_source = prefs.get("last_source")
|
|
708
|
+
|
|
709
|
+
picked = _run_picker_screen(default_tool_ids, default_source)
|
|
710
|
+
clear_screen()
|
|
711
|
+
|
|
712
|
+
if picked is None:
|
|
713
|
+
print("Goodbye!")
|
|
714
|
+
cleanup_all_services()
|
|
715
|
+
return
|
|
716
|
+
|
|
717
|
+
if picked[0] == "provider":
|
|
718
|
+
_, provider = picked
|
|
719
|
+
_run_data_provider(provider)
|
|
720
|
+
elif picked[0] == "tools":
|
|
721
|
+
_, tool_ids, source = picked
|
|
722
|
+
_launch(tool_ids, source)
|
|
723
|
+
|
|
724
|
+
input("\n Press Enter to continue... ")
|
|
725
|
+
|
|
726
|
+
except (EOFError, KeyboardInterrupt):
|
|
727
|
+
print("Goodbye!")
|
|
728
|
+
cleanup_all_services()
|
|
729
|
+
return
|