Coverage for src/pytribeam/command_line.py: 27%
155 statements
« prev ^ index » next coverage.py v7.6.1, created at 2026-09-09 18:27 +0000
« prev ^ index » next coverage.py v7.6.1, created at 2026-09-09 18:27 +0000
1#!/usr/bin/python3
2"""
3Command-line entry points for `pytribeam`.
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.
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.
16## Console commands
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. |
26## Examples
28```console
29$ pytribeam
30$ pytribeam_info
31$ pytribeam_gui
32$ pytribeam_exp path/to/experiment.yml
33```
35## Import behavior
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.
41<hr style="height: 12px; background-color: #333; border: none;">
42"""
44from __future__ import annotations
46import os
47import sys
48import shutil
49import subprocess
50import argparse
51from pathlib import Path
52from typing import Final, Optional
54CLI_DOCS: Final[str] = """
55--------
56pytribeam
57--------
59pytribeam
60 Prints this command line documentation.
62pytribeam_info
63 Prints the module version, supported AutoScript and Laser API versions,
64 and detected installed environment.
66pytribeam_gui
67 Launches the GUI for creating configuration .yml files and controlling
68 experimental collection.
70pytribeam_exp <path_to_file>.yml
71 Runs the 3D data collection workflow based on an input .yml file.
73pytribeam_exp --help
74 Prints help for the experiment command.
76Example:
77 path/to/experiment/directory> pytribeam_exp path/to/config/file.yml
78"""
81def work_in_progress():
82 """
83 Prints the 'Work in Progress (WIP)' warning message to the console.
85 This function prints a warning message indicating that the function is a
86 work in progress and has not yet been implemented.
88 Parameters
89 ----------
90 None
92 Returns
93 -------
94 None
95 """
96 print("Warning: Work in progress (WIP), function not yet implemented.")
99# ----------------------------
100# ------- pytribeam --------
101# ----------------------------
104def pytribeam():
105 """
106 Prints the command line documentation to the command window.
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.
112 Parameters
113 ----------
114 None
116 Returns
117 -------
118 None
119 """
120 print(CLI_DOCS.strip())
123# ----------------------------
124# ----- pytribeam_info -----
125# ----------------------------
128def module_info() -> None:
129 """
130 Prints lightweight package and environment information.
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
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()
143 print(f"{pm.MODULE_SHORT_NAME} module version: v{pytribeam_version or 'unknown'}")
145 if pytribeam_commit:
146 print(f" Git commit: {pytribeam_commit}")
148 print(f" Maximum supported .yml schema version: v{pm.YML_SCHEMA_VERSION}")
150 print(
151 " Supported Thermo Fisher AutoScript versions: "
152 + ", ".join(f"v{x}" for x in pm.SUPPORTED_AUTOSCRIPT_VERSIONS)
153 )
155 print(
156 " Supported Laser API versions: "
157 + ", ".join(f"v{x}" for x in pm.SUPPORTED_LASER_API_VERSIONS)
158 )
160 print()
161 print("Installed environment:")
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 )
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 )
191# ----------------------------
192# ----- pytribeam_gui ------
193# ----------------------------
196def launch_gui() -> None:
197 """
198 Launches the pytribeam GUI.
200 GUI imports are intentionally delayed until this function is called.
201 """
202 import pytribeam.GUI.runner as runner
204 app = runner.MainApplication()
205 app.mainloop()
208# ----------------------------
209# ----- pytribeam_exp ------
210# ----------------------------
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 )
221 parser.add_argument(
222 "file_path",
223 type=str,
224 help="Path to the experiment configuration .yml file.",
225 )
227 return parser
230def run_experiment() -> None:
231 """
232 Runs an experiment from the command line.
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 """
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.")
250 parser = build_experiment_parser()
251 args = parser.parse_args()
253 start_slice = _positive_integer("Starting slice: ")
254 start_step = _positive_integer("Starting step: ")
256 import pytribeam.workflow as workflow
258 workflow.run_experiment_cli(
259 start_slice=start_slice,
260 start_step=start_step,
261 yml_path=Path(args.file_path),
262 )
265# ----------------------------
266# ---- pytribeam_setup -----
267# ----------------------------
269NEEDED_AUTOSCRIPT_PACKAGES = [
270 "autoscript_core",
271 "autoscript_sdb_microscope_client",
272 "autoscript_toolkit",
273 "thermoscientific_logging",
274]
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 )
285 parser.add_argument(
286 "-f",
287 "--folder",
288 default=None,
289 help="Path to the folder containing the autoscript wheels.",
290 )
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 )
304 parser.add_argument(
305 "--upgrade",
306 action="store_true",
307 help="Pass --upgrade to the installer.",
308 )
310 return parser
313def _normalize_package_name(name: str) -> str:
314 """
315 Normalize package names enough for matching wheel filenames.
317 Wheel filenames generally begin with:
319 distribution-version-...
321 For these AutoScript wheels, this should be sufficient.
322 """
323 return name.lower().replace("-", "_")
326def _wheel_distribution_name(wheel: Path) -> str:
327 """
328 Extract the distribution portion from a wheel filename.
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])
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 )
350 program_files = os.environ.get("ProgramFiles")
351 if program_files is None:
352 raise RuntimeError("Unable to determine the Program Files directory.")
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 )
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 )
368 if not wheel_folder.is_dir():
369 raise RuntimeError(f"The wheel folder does not exist: {wheel_folder}")
371 return wheel_folder
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"))
380 if not wheels:
381 raise RuntimeError(f"No wheel files were found in {folder}.")
383 wheels_by_distribution: dict[str, list[Path]] = {}
385 for wheel in wheels:
386 distribution_name = _wheel_distribution_name(wheel)
387 wheels_by_distribution.setdefault(distribution_name, []).append(wheel)
389 selected_wheels: list[Path] = []
390 missing_packages: list[str] = []
392 for package in NEEDED_AUTOSCRIPT_PACKAGES:
393 normalized_package = _normalize_package_name(package)
394 matches = wheels_by_distribution.get(normalized_package, [])
396 if not matches:
397 missing_packages.append(package)
398 continue
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 )
407 selected_wheels.append(matches[0])
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 )
415 return selected_wheels
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
431def _choose_installer(requested: str, python_executable: str) -> str:
432 """
433 Choose pip or uv.
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
442 uv_available = shutil.which("uv") is not None
443 pip_available = _pip_available(python_executable)
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"
449 # If this looks like a uv-managed project, prefer uv.
450 if uv_available and Path("uv.lock").is_file():
451 return "uv"
453 # Otherwise, use pip through the current interpreter.
454 if pip_available:
455 return "pip"
457 if uv_available:
458 return "uv"
460 raise RuntimeError(
461 "Neither pip nor uv appears to be available. Cannot install wheels."
462 )
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]
476 if installer == "pip":
477 cmd = [python_executable, "-m", "pip", "install"]
479 if upgrade:
480 cmd.append("--upgrade")
482 cmd.extend(wheel_args)
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 )
490 cmd = [
491 "uv",
492 "pip",
493 "install",
494 "--python",
495 python_executable,
496 ]
498 if upgrade:
499 cmd.append("--upgrade")
501 cmd.extend(wheel_args)
503 else:
504 raise ValueError(f"Unknown installer: {installer}")
506 print("Installing AutoScript wheels with command:")
507 print(" ".join(cmd))
509 subprocess.run(cmd, check=True)
512def setup_env() -> None:
513 """
514 Setup current python environment for AutoScript.
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()
522 folder = _find_autoscript_folder(args.folder)
523 wheels = _find_required_wheels(folder)
525 installer = _choose_installer(args.installer, sys.executable)
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}")
533 _install_wheels(
534 wheels=wheels,
535 installer=installer,
536 upgrade=args.upgrade,
537 python_executable=sys.executable,
538 )