From e8333d5aa30db6ec412f80eb24971f74a2ee4043 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Fri, 18 Jul 2025 17:09:15 -0700 Subject: [PATCH] Add import error details for optional dependencies --- pylabrobot/audio/audio.py | 7 +++-- pylabrobot/io/ftdi.py | 5 ++-- pylabrobot/io/hid.py | 7 +++-- pylabrobot/io/serial.py | 5 ++-- pylabrobot/liquid_handling/backends/http.py | 7 +++-- .../backends/opentrons_backend.py | 4 ++- .../liquid_handling/backends/websocket.py | 11 ++++++-- pylabrobot/plate_reading/biotek_backend.py | 28 ++++++++++++++----- .../pumps/agrowpumps/agrowdosepump_backend.py | 10 +++++-- pylabrobot/resources/opentrons/load.py | 7 +++-- .../opentrons_backend.py | 4 ++- pylabrobot/tilting/hamilton_backend.py | 8 +++++- pylabrobot/visualizer/visualizer.py | 7 +++-- 13 files changed, 81 insertions(+), 29 deletions(-) diff --git a/pylabrobot/audio/audio.py b/pylabrobot/audio/audio.py index 266a16e96b6..37b03f63ec1 100644 --- a/pylabrobot/audio/audio.py +++ b/pylabrobot/audio/audio.py @@ -9,15 +9,18 @@ from IPython.display import Audio, display USE_AUDIO = True -except ImportError: +except ImportError as e: USE_AUDIO = False + _AUDIO_IMPORT_ERROR = e def _audio_check(func): @functools.wraps(func) def wrapper(*args, **kwargs): if not USE_AUDIO: - return + raise RuntimeError( + f"Audio functionality requires IPython.display. Import error: {_AUDIO_IMPORT_ERROR}" + ) return func(*args, **kwargs) return wrapper diff --git a/pylabrobot/io/ftdi.py b/pylabrobot/io/ftdi.py index b50e526fb92..d4ad28cb8f5 100644 --- a/pylabrobot/io/ftdi.py +++ b/pylabrobot/io/ftdi.py @@ -9,8 +9,9 @@ from pylibftdi import Device HAS_PYLIBFTDI = True -except ImportError: +except ImportError as e: HAS_PYLIBFTDI = False + _FTDI_IMPORT_ERROR = e from pylabrobot.io.capture import CaptureReader, Command, capturer, get_capture_or_validation_active from pylabrobot.io.errors import ValidationError @@ -38,7 +39,7 @@ def __init__(self, device_id: Optional[str] = None): async def setup(self): if not HAS_PYLIBFTDI: - raise RuntimeError("pylibftdi not installed.") + raise RuntimeError(f"pylibftdi not installed. Import error: {_FTDI_IMPORT_ERROR}") self._dev.open() self._executor = ThreadPoolExecutor(max_workers=1) diff --git a/pylabrobot/io/hid.py b/pylabrobot/io/hid.py index 453835ad842..4eb8a2e5b14 100644 --- a/pylabrobot/io/hid.py +++ b/pylabrobot/io/hid.py @@ -12,8 +12,9 @@ import hid # type: ignore USE_HID = True -except ImportError: +except ImportError as e: USE_HID = False + _HID_IMPORT_ERROR = e logger = logging.getLogger(__name__) @@ -40,7 +41,9 @@ def __init__(self, vid=0x03EB, pid=0x2023, serial_number: Optional[str] = None): async def setup(self): if not USE_HID: - raise RuntimeError("This backend requires the `hid` package to be installed") + raise RuntimeError( + f"This backend requires the `hid` package to be installed. Import error: {_HID_IMPORT_ERROR}" + ) self.device = hid.Device(vid=self.vid, pid=self.pid, serial=self.serial_number) self._executor = ThreadPoolExecutor(max_workers=1) logger.log(LOG_LEVEL_IO, "Opened HID device %s", self._unique_id) diff --git a/pylabrobot/io/serial.py b/pylabrobot/io/serial.py index b2706182cf3..5e35289087b 100644 --- a/pylabrobot/io/serial.py +++ b/pylabrobot/io/serial.py @@ -11,8 +11,9 @@ import serial HAS_SERIAL = True -except ImportError: +except ImportError as e: HAS_SERIAL = False + _SERIAL_IMPORT_ERROR = e from pylabrobot.io.capture import CaptureReader, Command, capturer, get_capture_or_validation_active from pylabrobot.io.validation_utils import LOG_LEVEL_IO, align_sequences @@ -63,7 +64,7 @@ def port(self) -> str: async def setup(self): if not HAS_SERIAL: - raise RuntimeError("pyserial not installed.") + raise RuntimeError(f"pyserial not installed. Import error: {_SERIAL_IMPORT_ERROR}") loop = asyncio.get_running_loop() self._executor = ThreadPoolExecutor(max_workers=1) diff --git a/pylabrobot/liquid_handling/backends/http.py b/pylabrobot/liquid_handling/backends/http.py index 0b21072b0e8..e4f84f70640 100644 --- a/pylabrobot/liquid_handling/backends/http.py +++ b/pylabrobot/liquid_handling/backends/http.py @@ -10,8 +10,9 @@ import requests HAS_REQUESTS = True -except ImportError: +except ImportError as e: HAS_REQUESTS = False + _REQUESTS_IMPORT_ERROR = e class HTTPBackend(SerializingBackend): @@ -44,7 +45,9 @@ def __init__( """ if not HAS_REQUESTS: - raise RuntimeError("The http backend requires the requests module.") + raise RuntimeError( + f"The http backend requires the requests module. Import error: {_REQUESTS_IMPORT_ERROR}" + ) super().__init__(num_channels=num_channels) self.session: Optional[requests.Session] = None diff --git a/pylabrobot/liquid_handling/backends/opentrons_backend.py b/pylabrobot/liquid_handling/backends/opentrons_backend.py index 91ed30ead8d..dfe83af5a30 100644 --- a/pylabrobot/liquid_handling/backends/opentrons_backend.py +++ b/pylabrobot/liquid_handling/backends/opentrons_backend.py @@ -43,8 +43,9 @@ from requests import HTTPError USE_OT = True - except ImportError: + except ImportError as e: USE_OT = False + _OT_IMPORT_ERROR = e else: USE_OT = False @@ -79,6 +80,7 @@ def __init__(self, host: str, port: int = 31950): if not USE_OT: raise RuntimeError( "Opentrons is not installed. Please run pip install pylabrobot[opentrons]." + f" Import error: {_OT_IMPORT_ERROR}." " Only supported on Python 3.10 and below." ) diff --git a/pylabrobot/liquid_handling/backends/websocket.py b/pylabrobot/liquid_handling/backends/websocket.py index 34b832372c3..d4d21afb5cc 100644 --- a/pylabrobot/liquid_handling/backends/websocket.py +++ b/pylabrobot/liquid_handling/backends/websocket.py @@ -12,8 +12,9 @@ import websockets.legacy.server HAS_WEBSOCKETS = True -except ImportError: +except ImportError as e: HAS_WEBSOCKETS = False + _WEBSOCKETS_IMPORT_ERROR = e from pylabrobot.__version__ import STANDARD_FORM_JSON_VERSION from pylabrobot.liquid_handling.backends.serializing_backend import ( @@ -46,7 +47,9 @@ def __init__( """ if not HAS_WEBSOCKETS: - raise RuntimeError("The WebSocketBackend requires websockets to be installed.") + raise RuntimeError( + f"The WebSocketBackend requires websockets to be installed. Import error: {_WEBSOCKETS_IMPORT_ERROR}" + ) super().__init__(num_channels=num_channels) self._websocket: Optional["websockets.legacy.server.WebSocketServerProtocol"] = None @@ -252,7 +255,9 @@ async def setup(self): """Start the websocket server. This will run in a separate thread.""" if not HAS_WEBSOCKETS: - raise RuntimeError("The WebSocketBackend requires websockets to be installed.") + raise RuntimeError( + f"The WebSocketBackend requires websockets to be installed. Import error: {_WEBSOCKETS_IMPORT_ERROR}" + ) async def run_server(): self._stop_ = self.loop.create_future() diff --git a/pylabrobot/plate_reading/biotek_backend.py b/pylabrobot/plate_reading/biotek_backend.py index 066e25e2bc0..c2e67b95609 100644 --- a/pylabrobot/plate_reading/biotek_backend.py +++ b/pylabrobot/plate_reading/biotek_backend.py @@ -9,8 +9,12 @@ try: import cv2 # type: ignore -except ImportError: + + CV2_AVAILABLE = True +except ImportError as e: cv2 = None # type: ignore + CV2_AVAILABLE = False + _CV2_IMPORT_ERROR = e from pylabrobot.resources.plate import Plate @@ -18,16 +22,18 @@ import numpy as np # type: ignore USE_NUMPY = True -except ImportError: +except ImportError as e: USE_NUMPY = False + _NUMPY_IMPORT_ERROR = e try: import PySpin # type: ignore # can be downloaded from https://www.teledynevisionsolutions.com/products/spinnaker-sdk/ USE_PYSPIN = True -except ImportError: +except ImportError as e: USE_PYSPIN = False + _PYSPIN_IMPORT_ERROR = e from pylabrobot.io.ftdi import FTDI from pylabrobot.plate_reading.backend import ImageReaderBackend @@ -149,7 +155,10 @@ async def setup(self, use_cam: bool = False) -> None: if use_cam: if not USE_PYSPIN: - raise RuntimeError("PySpin is not installed. Please follow the imaging setup instructions.") + raise RuntimeError( + "PySpin is not installed. Please follow the imaging setup instructions. " + f"Import error: {_PYSPIN_IMPORT_ERROR}" + ) if self.imaging_config is None: raise RuntimeError("Imaging configuration is not set.") @@ -866,7 +875,10 @@ async def auto_focus(self, timeout: float = 30): raise RuntimeError("Row and column not set. Run select() first.") if not USE_NUMPY: # This is strange, because Spinnaker requires numpy - raise RuntimeError("numpy is not installed. See Cytation5 installation instructions.") + raise RuntimeError( + "numpy is not installed. See Cytation5 installation instructions. " + f"Import error: {_NUMPY_IMPORT_ERROR}" + ) # objective function: variance of laplacian async def evaluate_focus(focus_value): @@ -882,8 +894,10 @@ async def evaluate_focus(focus_value): ) image = images[0] # self.capture returns List now - if cv2 is None: - raise RuntimeError("cv2 needs to be installed for auto focus") + if not CV2_AVAILABLE: + raise RuntimeError( + f"cv2 needs to be installed for auto focus. Import error: {_CV2_IMPORT_ERROR}" + ) # NVMG: Normalized Variance of the Gradient Magnitude # Chat invented this i think diff --git a/pylabrobot/pumps/agrowpumps/agrowdosepump_backend.py b/pylabrobot/pumps/agrowpumps/agrowdosepump_backend.py index 1c2b400a25e..47d9e28f6f8 100644 --- a/pylabrobot/pumps/agrowpumps/agrowdosepump_backend.py +++ b/pylabrobot/pumps/agrowpumps/agrowdosepump_backend.py @@ -6,8 +6,11 @@ try: from pymodbus.client import AsyncModbusSerialClient # type: ignore -except ImportError: + + _MODBUS_IMPORT_ERROR = None +except ImportError as e: AsyncModbusSerialClient = None # type: ignore + _MODBUS_IMPORT_ERROR = e from pylabrobot.pumps.backend import PumpArrayBackend @@ -117,7 +120,10 @@ async def setup(self): async def _setup_modbus(self): if AsyncModbusSerialClient is None: - raise RuntimeError("pymodbus is not installed. Please install it with 'pip install pymodbus'") + raise RuntimeError( + "pymodbus is not installed. Please install it with 'pip install pymodbus'." + f" Import error: {_MODBUS_IMPORT_ERROR}" + ) self._modbus = AsyncModbusSerialClient( port=self.port, baudrate=115200, diff --git a/pylabrobot/resources/opentrons/load.py b/pylabrobot/resources/opentrons/load.py index 2f86ab42349..ae12397d40f 100644 --- a/pylabrobot/resources/opentrons/load.py +++ b/pylabrobot/resources/opentrons/load.py @@ -6,8 +6,9 @@ import opentrons_shared_data.labware USE_OT = True -except ImportError: +except ImportError as e: USE_OT = False + _OT_IMPORT_ERROR = e from pylabrobot.resources.coordinate import Coordinate from pylabrobot.resources.plate import Plate @@ -35,7 +36,9 @@ def ot_definition_to_resource( if not USE_OT: raise ImportError( - "opentrons_shared_data is not installed. " "run `pip install opentrons_shared_data`" + "opentrons_shared_data is not installed. " + f"Import error: {_OT_IMPORT_ERROR}. " + "run `pip install opentrons_shared_data`" ) display_category = data["metadata"]["displayCategory"] diff --git a/pylabrobot/temperature_controlling/opentrons_backend.py b/pylabrobot/temperature_controlling/opentrons_backend.py index bfdb8784562..e196cb486ff 100644 --- a/pylabrobot/temperature_controlling/opentrons_backend.py +++ b/pylabrobot/temperature_controlling/opentrons_backend.py @@ -12,8 +12,9 @@ import ot_api USE_OT = True - except ImportError: + except ImportError as e: USE_OT = False + _OT_IMPORT_ERROR = e else: USE_OT = False @@ -37,6 +38,7 @@ def __init__(self, opentrons_id: str): if not USE_OT: raise RuntimeError( "Opentrons is not installed. Please run pip install pylabrobot[opentrons]." + f" Import error: {_OT_IMPORT_ERROR}." " Only supported on Python 3.10." ) diff --git a/pylabrobot/tilting/hamilton_backend.py b/pylabrobot/tilting/hamilton_backend.py index 9d6d0f09a86..76972bf208f 100644 --- a/pylabrobot/tilting/hamilton_backend.py +++ b/pylabrobot/tilting/hamilton_backend.py @@ -5,8 +5,9 @@ import serial HAS_SERIAL = True -except ImportError: +except ImportError as e: HAS_SERIAL = False + _SERIAL_IMPORT_ERROR = e from pylabrobot.io.serial import Serial from pylabrobot.tilting.tilter_backend import ( @@ -24,6 +25,11 @@ def __init__( write_timeout: float = 10, timeout: float = 10, ): + if not HAS_SERIAL: + raise RuntimeError( + f"pyserial is required for the Hamilton tilt module backend. Import error: {_SERIAL_IMPORT_ERROR}" + ) + self.setup_finished = False self.com_port = com_port self.timeout = timeout diff --git a/pylabrobot/visualizer/visualizer.py b/pylabrobot/visualizer/visualizer.py index 0b7ac36fd02..73732582a2f 100644 --- a/pylabrobot/visualizer/visualizer.py +++ b/pylabrobot/visualizer/visualizer.py @@ -15,8 +15,9 @@ import websockets.legacy.server HAS_WEBSOCKETS = True -except ImportError: +except ImportError as e: HAS_WEBSOCKETS = False + _WEBSOCKETS_IMPORT_ERROR = e from pylabrobot.__version__ import STANDARD_FORM_JSON_VERSION from pylabrobot.resources import Resource @@ -277,7 +278,9 @@ async def _run_ws_server(self): """ if not HAS_WEBSOCKETS: - raise RuntimeError("The visualizer requires websockets to be installed.") + raise RuntimeError( + f"The visualizer requires websockets to be installed. Import error: {_WEBSOCKETS_IMPORT_ERROR}" + ) async def run_server(): self._stop_ = self.loop.create_future()