Coverage for src/pytribeam/command_line.py: 27%

155 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2026-09-03 19:02 +0000

1#!/usr/bin/python3 

2""" 

3Command-line entry points for `pytribeam`. 

4 

5This module defines the functions used by the package's console-script entry 

6points. These commands provide lightweight access to package documentation, 

7installation/environment diagnostics, GUI startup, and experiment execution from 

8a configuration file. 

9 

10The command functions are intentionally small wrappers around package 

11functionality. Runtime-heavy imports, such as workflow, GUI, AutoScript, and 

12Laser API modules, are delayed until they are needed so that simple commands 

13such as help text and module information can run in environments without 

14microscope or laser runtime support. 

15 

16## Console commands 

17 

18| Function | Purpose | 

19| --- | --- | 

20| `pytribeam` | Print command-line documentation. | 

21| `module_info` | Print package, dependency, and runtime availability information. | 

22| `launch_gui` | Start the `pytribeam` graphical user interface. | 

23| `run_experiment` | Run an experiment from a configuration `.yml` file. | 

24| `work_in_progress` | Print a placeholder warning for unfinished commands. | 

25 

26## Examples 

27 

28```console 

29$ pytribeam 

30$ pytribeam_info 

31$ pytribeam_gui 

32$ pytribeam_exp path/to/experiment.yml 

33``` 

34 

35## Import behavior 

36 

37Runtime-heavy imports are delayed until the corresponding command is executed. 

38This keeps help text, package metadata inspection, and documentation commands 

39usable even when optional hardware-control dependencies are unavailable. 

40 

41<hr style="height: 12px; background-color: #333; border: none;"> 

42""" 

43 

44from __future__ import annotations 

45 

46import os 

47import sys 

48import shutil 

49import subprocess 

50import argparse 

51from pathlib import Path 

52from typing import Final, Optional 

53 

54CLI_DOCS: Final[str] = """ 

55-------- 

56pytribeam 

57-------- 

58 

59pytribeam 

60 Prints this command line documentation. 

61 

62pytribeam_info 

63 Prints the module version, supported AutoScript and Laser API versions, 

64 and detected installed environment. 

65 

66pytribeam_gui 

67 Launches the GUI for creating configuration .yml files and controlling 

68 experimental collection. 

69 

70pytribeam_exp <path_to_file>.yml 

71 Runs the 3D data collection workflow based on an input .yml file. 

72 

73pytribeam_exp --help 

74 Prints help for the experiment command. 

75 

76Example: 

77 path/to/experiment/directory> pytribeam_exp path/to/config/file.yml 

78""" 

79 

80 

81def work_in_progress(): 

82 """ 

83 Prints the 'Work in Progress (WIP)' warning message to the console. 

84 

85 This function prints a warning message indicating that the function is a 

86 work in progress and has not yet been implemented. 

87 

88 Parameters 

89 ---------- 

90 None 

91 

92 Returns 

93 ------- 

94 None 

95 """ 

96 print("Warning: Work in progress (WIP), function not yet implemented.") 

97 

98 

99# ---------------------------- 

100# ------- pytribeam -------- 

101# ---------------------------- 

102 

103 

104def pytribeam(): 

105 """ 

106 Prints the command line documentation to the command window. 

107 

108 This function prints the contents of the global variable `CLI_DOCS` to the 

109 command window. It is assumed that `CLI_DOCS` contains the necessary 

110 documentation in string format. 

111 

112 Parameters 

113 ---------- 

114 None 

115 

116 Returns 

117 ------- 

118 None 

119 """ 

120 print(CLI_DOCS.strip()) 

121 

122 

123# ---------------------------- 

124# ----- pytribeam_info ----- 

125# ---------------------------- 

126 

127 

128def module_info() -> None: 

129 """ 

130 Prints lightweight package and environment information. 

131 

132 This command is intended to verify installation and should not require a 

133 microscope connection, AutoScript runtime initialization, Laser API runtime 

134 initialization, or a license check. 

135 """ 

136 import pytribeam._package_metadata as pm 

137 

138 pytribeam_version = pm.get_pytribeam_version() 

139 pytribeam_commit = pm.get_pytribeam_commit_id() 

140 autoscript_version = pm.get_autoscript_version() 

141 laser_version = pm.get_laser_api_version() 

142 

143 print(f"{pm.MODULE_SHORT_NAME} module version: v{pytribeam_version or 'unknown'}") 

144 

145 if pytribeam_commit: 

146 print(f" Git commit: {pytribeam_commit}") 

147 

148 print(f" Maximum supported .yml schema version: v{pm.YML_SCHEMA_VERSION}") 

149 

150 print( 

151 " Supported Thermo Fisher AutoScript versions: " 

152 + ", ".join(f"v{x}" for x in pm.SUPPORTED_AUTOSCRIPT_VERSIONS) 

153 ) 

154 

155 print( 

156 " Supported Laser API versions: " 

157 + ", ".join(f"v{x}" for x in pm.SUPPORTED_LASER_API_VERSIONS) 

158 ) 

159 

160 print() 

161 print("Installed environment:") 

162 

163 print(" AutoScript:") 

164 print( 

165 " Distribution metadata: " 

166 f"{'detected' if autoscript_version else 'not detected'}, " 

167 f"version: {autoscript_version or 'not detected'}" 

168 ) 

169 print( 

170 " Import package autoscript_sdb_microscope_client: " 

171 f"{'available' if pm.autoscript_available() else 'not importable'}" 

172 ) 

173 

174 print() 

175 print(" Laser API:") 

176 print( 

177 " Distribution metadata: " 

178 f"{'detected' if laser_version else 'not detected'}, " 

179 f"version: {laser_version or 'not detected'}" 

180 ) 

181 print( 

182 " Import package Laser: " 

183 f"{'available' if pm.laser_api_available() else 'not importable'}" 

184 ) 

185 print( 

186 " Import package Laser.PythonControl: " 

187 f"{'available' if pm.laser_pythoncontrol_available() else 'not importable'}" 

188 ) 

189 

190 

191# ---------------------------- 

192# ----- pytribeam_gui ------ 

193# ---------------------------- 

194 

195 

196def launch_gui() -> None: 

197 """ 

198 Launches the pytribeam GUI. 

199 

200 GUI imports are intentionally delayed until this function is called. 

201 """ 

202 import pytribeam.GUI.runner as runner 

203 

204 app = runner.MainApplication() 

205 app.mainloop() 

206 

207 

208# ---------------------------- 

209# ----- pytribeam_exp ------ 

210# ---------------------------- 

211 

212 

213def build_experiment_parser() -> argparse.ArgumentParser: 

214 """ 

215 Builds the argument parser for the pytribeam_exp command. 

216 """ 

217 parser = argparse.ArgumentParser( 

218 description="Run a pytribeam experiment from a configuration .yml file." 

219 ) 

220 

221 parser.add_argument( 

222 "file_path", 

223 type=str, 

224 help="Path to the experiment configuration .yml file.", 

225 ) 

226 

227 return parser 

228 

229 

230def run_experiment() -> None: 

231 """ 

232 Runs an experiment from the command line. 

233 

234 The workflow import is intentionally delayed until after argument parsing. 

235 This allows `pytribeam_exp --help` to run without importing workflow, 

236 AutoScript, or Laser runtime modules. 

237 """ 

238 

239 def _positive_integer(prompt: str) -> int: 

240 """Helper function to get valid integer input.""" 

241 while True: 

242 try: 

243 value = int(input(prompt)) 

244 if value > 0: 

245 return value 

246 print("Invalid input. Please enter an integer greater than 0.") 

247 except ValueError: 

248 print("Invalid input. Please enter a valid integer.") 

249 

250 parser = build_experiment_parser() 

251 args = parser.parse_args() 

252 

253 start_slice = _positive_integer("Starting slice: ") 

254 start_step = _positive_integer("Starting step: ") 

255 

256 import pytribeam.workflow as workflow 

257 

258 workflow.run_experiment_cli( 

259 start_slice=start_slice, 

260 start_step=start_step, 

261 yml_path=Path(args.file_path), 

262 ) 

263 

264 

265# ---------------------------- 

266# ---- pytribeam_setup ----- 

267# ---------------------------- 

268 

269NEEDED_AUTOSCRIPT_PACKAGES = [ 

270 "autoscript_core", 

271 "autoscript_sdb_microscope_client", 

272 "autoscript_toolkit", 

273 "thermoscientific_logging", 

274] 

275 

276 

277def _build_setup_parser() -> argparse.ArgumentParser: 

278 """ 

279 Builds the argument parser for the pytribeam_exp command. 

280 """ 

281 parser = argparse.ArgumentParser( 

282 description="Install AutoScript packages in the local environment." 

283 ) 

284 

285 parser.add_argument( 

286 "-f", 

287 "--folder", 

288 default=None, 

289 help="Path to the folder containing the autoscript wheels.", 

290 ) 

291 

292 parser.add_argument( 

293 "--installer", 

294 choices=["auto", "pip", "uv"], 

295 default="auto", 

296 help=( 

297 "Installer to use. " 

298 "'pip' uses the current Python interpreter via `python -m pip`; " 

299 "'uv' uses `uv pip install --python`; " 

300 "'auto' attempts to choose a reasonable default." 

301 ), 

302 ) 

303 

304 parser.add_argument( 

305 "--upgrade", 

306 action="store_true", 

307 help="Pass --upgrade to the installer.", 

308 ) 

309 

310 return parser 

311 

312 

313def _normalize_package_name(name: str) -> str: 

314 """ 

315 Normalize package names enough for matching wheel filenames. 

316 

317 Wheel filenames generally begin with: 

318 

319 distribution-version-... 

320 

321 For these AutoScript wheels, this should be sufficient. 

322 """ 

323 return name.lower().replace("-", "_") 

324 

325 

326def _wheel_distribution_name(wheel: Path) -> str: 

327 """ 

328 Extract the distribution portion from a wheel filename. 

329 

330 Example: 

331 autoscript_core-1.2.3-py3-none-any.whl -> autoscript_core 

332 """ 

333 return _normalize_package_name(wheel.name.split("-", 1)[0]) 

334 

335 

336def _find_autoscript_folder(folder: Optional[str]) -> Path: 

337 """ 

338 Find the folder containing the AutoScript wheels. 

339 """ 

340 if folder is not None: 

341 wheel_folder = Path(folder).expanduser().resolve() 

342 else: 

343 if os.name != "nt": 

344 raise RuntimeError( 

345 "Automatic identification of the Thermo Scientific AutoScript " 

346 "folder is not possible on non-Windows machines. Please provide " 

347 "the folder explicitly with --folder." 

348 ) 

349 

350 program_files = os.environ.get("ProgramFiles") 

351 if program_files is None: 

352 raise RuntimeError("Unable to determine the Program Files directory.") 

353 

354 autoscript_folder = Path(program_files) / "Thermo Scientific AutoScript" 

355 if not autoscript_folder.is_dir(): 

356 raise RuntimeError( 

357 "Unable to find the Thermo Scientific AutoScript folder in " 

358 "Program Files." 

359 ) 

360 

361 wheel_folder = autoscript_folder / "PythonPackages" 

362 if not wheel_folder.is_dir(): 

363 raise RuntimeError( 

364 "Unable to find the 'PythonPackages' folder in the Thermo " 

365 "Scientific AutoScript folder in Program Files." 

366 ) 

367 

368 if not wheel_folder.is_dir(): 

369 raise RuntimeError(f"The wheel folder does not exist: {wheel_folder}") 

370 

371 return wheel_folder 

372 

373 

374def _find_required_wheels(folder: Path) -> list[Path]: 

375 """ 

376 Find the required AutoScript wheels in the given folder. 

377 """ 

378 wheels = sorted(folder.glob("*.whl")) 

379 

380 if not wheels: 

381 raise RuntimeError(f"No wheel files were found in {folder}.") 

382 

383 wheels_by_distribution: dict[str, list[Path]] = {} 

384 

385 for wheel in wheels: 

386 distribution_name = _wheel_distribution_name(wheel) 

387 wheels_by_distribution.setdefault(distribution_name, []).append(wheel) 

388 

389 selected_wheels: list[Path] = [] 

390 missing_packages: list[str] = [] 

391 

392 for package in NEEDED_AUTOSCRIPT_PACKAGES: 

393 normalized_package = _normalize_package_name(package) 

394 matches = wheels_by_distribution.get(normalized_package, []) 

395 

396 if not matches: 

397 missing_packages.append(package) 

398 continue 

399 

400 if len(matches) > 1: 

401 raise RuntimeError( 

402 f"Found multiple wheels for package {package!r}: " 

403 f"{[str(w) for w in matches]}. Please remove duplicates or " 

404 "provide a folder with only one version of each required wheel." 

405 ) 

406 

407 selected_wheels.append(matches[0]) 

408 

409 if missing_packages: 

410 raise RuntimeError( 

411 f"Unable to find all necessary wheels in {folder}. " 

412 f"Missing wheels for packages: {missing_packages}." 

413 ) 

414 

415 return selected_wheels 

416 

417 

418def _pip_available(python_executable: str) -> bool: 

419 """ 

420 Return True if `python -m pip` is available for the given interpreter. 

421 """ 

422 result = subprocess.run( 

423 [python_executable, "-m", "pip", "--version"], 

424 stdout=subprocess.DEVNULL, 

425 stderr=subprocess.DEVNULL, 

426 check=False, 

427 ) 

428 return result.returncode == 0 

429 

430 

431def _choose_installer(requested: str, python_executable: str) -> str: 

432 """ 

433 Choose pip or uv. 

434 

435 This is intentionally conservative. There is no perfect way to know whether 

436 the user considers the environment to be managed by uv, pip, venv, conda, 

437 etc. 

438 """ 

439 if requested != "auto": 

440 return requested 

441 

442 uv_available = shutil.which("uv") is not None 

443 pip_available = _pip_available(python_executable) 

444 

445 # If pip is not installed but uv is available, uv is the better option. 

446 if uv_available and not pip_available: 

447 return "uv" 

448 

449 # If this looks like a uv-managed project, prefer uv. 

450 if uv_available and Path("uv.lock").is_file(): 

451 return "uv" 

452 

453 # Otherwise, use pip through the current interpreter. 

454 if pip_available: 

455 return "pip" 

456 

457 if uv_available: 

458 return "uv" 

459 

460 raise RuntimeError( 

461 "Neither pip nor uv appears to be available. Cannot install wheels." 

462 ) 

463 

464 

465def _install_wheels( 

466 wheels: list[Path], 

467 installer: str, 

468 upgrade: bool = False, 

469 python_executable: str = sys.executable, 

470) -> None: 

471 """ 

472 Install wheels into the environment associated with python_executable. 

473 """ 

474 wheel_args = [str(wheel) for wheel in wheels] 

475 

476 if installer == "pip": 

477 cmd = [python_executable, "-m", "pip", "install"] 

478 

479 if upgrade: 

480 cmd.append("--upgrade") 

481 

482 cmd.extend(wheel_args) 

483 

484 elif installer == "uv": 

485 if shutil.which("uv") is None: 

486 raise RuntimeError( 

487 "The requested installer was 'uv', but uv was not found on PATH." 

488 ) 

489 

490 cmd = [ 

491 "uv", 

492 "pip", 

493 "install", 

494 "--python", 

495 python_executable, 

496 ] 

497 

498 if upgrade: 

499 cmd.append("--upgrade") 

500 

501 cmd.extend(wheel_args) 

502 

503 else: 

504 raise ValueError(f"Unknown installer: {installer}") 

505 

506 print("Installing AutoScript wheels with command:") 

507 print(" ".join(cmd)) 

508 

509 subprocess.run(cmd, check=True) 

510 

511 

512def setup_env() -> None: 

513 """ 

514 Setup current python environment for AutoScript. 

515 

516 This only works if the AutoScript wheels are present on the system somewhere. 

517 The function will search in default locations if a folder is not provided. 

518 """ 

519 parser = _build_setup_parser() 

520 args = parser.parse_args() 

521 

522 folder = _find_autoscript_folder(args.folder) 

523 wheels = _find_required_wheels(folder) 

524 

525 installer = _choose_installer(args.installer, sys.executable) 

526 

527 print(f"Using Python interpreter: {sys.executable}") 

528 print(f"Using installer: {installer}") 

529 print("Wheels to install:") 

530 for wheel in wheels: 

531 print(f" {wheel}") 

532 

533 _install_wheels( 

534 wheels=wheels, 

535 installer=installer, 

536 upgrade=args.upgrade, 

537 python_executable=sys.executable, 

538 )