diff --git a/.github/actions/run-tests/action.yml b/.github/actions/run-tests/action.yml new file mode 100644 index 00000000000..f81b4494b8d --- /dev/null +++ b/.github/actions/run-tests/action.yml @@ -0,0 +1,30 @@ +name: "Run Tests" +description: "Sets up Python, installs dependencies, and runs tests" +inputs: + version: + description: "Python version" + required: true + extra: + description: "Optional dependency to install" + required: true +runs: + using: "composite" + steps: + - name: Update packages + run: sudo apt-get update + shell: bash + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: ${{ inputs.version }} + - name: Install Dependencies + run: | + if [ "${{ inputs.extra }}" = "" ]; then + pip install -e '.[test]' + else + pip install -e '.[test,${{ inputs.extra }}]' + fi + shell: bash + - name: Run Pytest + run: make test + shell: bash diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index abab4771333..dc61754c2ce 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,30 +3,45 @@ name: Unit Tests on: push: branches: - - main + - main pull_request: + workflow_dispatch: jobs: - test: + test-all: strategy: fail-fast: false matrix: os: [ubuntu-latest] version: [3.9, "3.10", 3.11, 3.12, 3.13, 3.14] - name: Tests + name: Tests (all, py${{ matrix.version }}) runs-on: ${{ matrix.os }} steps: - name: Checkout Code - uses: actions/checkout@v2 - - name: Update packages - run: sudo apt-get update - - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/checkout@v6 + - name: Run Tests + uses: ./.github/actions/run-tests with: - python-version: ${{ matrix.version }} - - name: Install Dependencies - run: pip install -e '.[dev]' - - name: Run Pytest - run: make test + version: ${{ matrix.version }} + extra: all + + test-extras: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + extra: ["", serial, usb, ftdi, hid, modbus, opentrons, sila, microscopy, pico] + + name: Tests (${{ matrix.extra }}, py3.12) + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout Code + uses: actions/checkout@v6 + - name: Run Tests + uses: ./.github/actions/run-tests + with: + version: "3.12" + extra: ${{ matrix.extra }} diff --git a/docs/user_guide/_getting-started/installation.md b/docs/user_guide/_getting-started/installation.md index 235c5165211..05d88c1ae58 100644 --- a/docs/user_guide/_getting-started/installation.md +++ b/docs/user_guide/_getting-started/installation.md @@ -49,6 +49,9 @@ Different machines use different communication modes. Replace `[usb]` with one o | `hid` | hid | HID devices: e.g. Inheco Incubator/Shaker (HID mode) | | `modbus` | pymodbus | Modbus devices: e.g. Agrow Pump Array | | `opentrons` | opentrons-http-api-client | e.g. Opentrons backend | +| `microscopy` | numpy (1.26), opencv-python | e.g. Cytation imager | +| `sila` | zeroconf, grpcio | SiLA devices | +| `pico` | microscopy + sila | ImageXpress Pico microscope | | `dev` | All of the above + testing/linting tools | Development | Or install all dependencies: @@ -57,6 +60,8 @@ Or install all dependencies: pip install 'pylabrobot[all]' ``` +Microscopy is not included in the `all` group because it requires an older version of numpy. If you want to use microscopy features, you need to install those dependencies separately through `pip install "pylabrobot[microscopy]"`. + ### From source You can install PyLabRobot from source. This is particularly useful if you want to contribute to the project or if you want to use the latest features that haven't been released on PyPI yet. diff --git a/pylabrobot/barcode_scanners/keyence/keyence_backend.py b/pylabrobot/barcode_scanners/keyence/keyence_backend.py index 8377e1142b1..e79501fda71 100644 --- a/pylabrobot/barcode_scanners/keyence/keyence_backend.py +++ b/pylabrobot/barcode_scanners/keyence/keyence_backend.py @@ -2,7 +2,13 @@ import logging import time -import serial +try: + import serial + + HAS_SERIAL = True +except ImportError as e: + HAS_SERIAL = False + _SERIAL_IMPORT_ERROR = e from pylabrobot.barcode_scanners.backend import ( BarcodeScannerBackend, @@ -24,6 +30,11 @@ def __init__( self, port: str, ): + if not HAS_SERIAL: + raise RuntimeError( + "pyserial is not installed. Install with: pip install pylabrobot[serial]. " + f"Import error: {_SERIAL_IMPORT_ERROR}" + ) super().__init__() # BL-1300 Barcode reader factory default serial communication settings diff --git a/pylabrobot/io/sila/discovery_tests.py b/pylabrobot/io/sila/discovery_tests.py index f6f8a7707f3..568f330727e 100644 --- a/pylabrobot/io/sila/discovery_tests.py +++ b/pylabrobot/io/sila/discovery_tests.py @@ -5,6 +5,7 @@ from unittest.mock import MagicMock, patch from pylabrobot.io.sila.discovery import ( + HAS_ZEROCONF, SiLADevice, _decode_nbns_name, _discover_sila2, @@ -137,9 +138,9 @@ def test_no_zeroconf_returns_empty(self): devices = asyncio.run(_discover_sila2(timeout=0.1)) self.assertEqual(devices, []) - @patch("pylabrobot.io.sila.discovery.HAS_ZEROCONF", True) - @patch("pylabrobot.io.sila.discovery.Zeroconf") - @patch("pylabrobot.io.sila.discovery.ServiceBrowser") + @unittest.skipIf(not HAS_ZEROCONF, "zeroconf not installed") + @patch("pylabrobot.io.sila.discovery.Zeroconf", create=True) + @patch("pylabrobot.io.sila.discovery.ServiceBrowser", create=True) def test_discovers_device(self, mock_browser_cls, mock_zc_cls): mock_zc = MagicMock() mock_zc_cls.return_value = mock_zc diff --git a/pylabrobot/liquid_handling/backends/opentrons_backend_tests.py b/pylabrobot/liquid_handling/backends/opentrons_backend_tests.py index 4d7c3d168e9..05ea8e2845f 100644 --- a/pylabrobot/liquid_handling/backends/opentrons_backend_tests.py +++ b/pylabrobot/liquid_handling/backends/opentrons_backend_tests.py @@ -1,6 +1,10 @@ import unittest from unittest.mock import patch +import pytest + +pytest.importorskip("ot_api") + from pylabrobot.liquid_handling import LiquidHandler from pylabrobot.liquid_handling.backends.opentrons_backend import ( OpentronsOT2Backend, diff --git a/pylabrobot/microscopes/molecular_devices/pico/backend_tests.py b/pylabrobot/microscopes/molecular_devices/pico/backend_tests.py index 513e87c9f7a..8e8c123ea65 100644 --- a/pylabrobot/microscopes/molecular_devices/pico/backend_tests.py +++ b/pylabrobot/microscopes/molecular_devices/pico/backend_tests.py @@ -14,7 +14,12 @@ from typing import Dict, List, Tuple from unittest.mock import patch -import numpy as np +import pytest + +pytest.importorskip("numpy") +pytest.importorskip("grpc") + +import numpy as np # type: ignore[import-not-found] from pylabrobot.io.sila.grpc import ( decode_fields, diff --git a/pylabrobot/peeling/xpeel_backend.py b/pylabrobot/peeling/xpeel_backend.py index ef4c887a551..8ea1b4a6983 100644 --- a/pylabrobot/peeling/xpeel_backend.py +++ b/pylabrobot/peeling/xpeel_backend.py @@ -3,7 +3,13 @@ from dataclasses import dataclass from typing import List, Literal, Tuple -import serial # type: ignore +try: + import serial # type: ignore + + HAS_SERIAL = True +except ImportError as e: + HAS_SERIAL = False + _SERIAL_IMPORT_ERROR = e from pylabrobot.io.serial import Serial from pylabrobot.peeling.backend import PeelerBackend @@ -43,6 +49,11 @@ class ErrorInfo: } def __init__(self, port: str, logger=None, timeout=None): + if not HAS_SERIAL: + raise RuntimeError( + "pyserial is not installed. Install with: pip install pylabrobot[serial]. " + f"Import error: {_SERIAL_IMPORT_ERROR}" + ) self.logger = logger or logging.getLogger(__name__) self.port = port self.response_timeout = timeout if timeout is not None else self.RESPONSE_TIMEOUT diff --git a/pylabrobot/plate_reading/agilent/biotek_synergyh1_backend.py b/pylabrobot/plate_reading/agilent/biotek_synergyh1_backend.py index 20aba4545cb..5036bb33b83 100644 --- a/pylabrobot/plate_reading/agilent/biotek_synergyh1_backend.py +++ b/pylabrobot/plate_reading/agilent/biotek_synergyh1_backend.py @@ -3,7 +3,13 @@ import time from typing import Optional -from pylibftdi import FtdiError +try: + from pylibftdi import FtdiError + + HAS_PYLIBFTDI = True +except ImportError: + HAS_PYLIBFTDI = False + FtdiError = Exception # type: ignore[misc,assignment] from pylabrobot.plate_reading.agilent.biotek_backend import BioTekPlateReaderBackend diff --git a/pylabrobot/plate_reading/agilent/biotek_tests.py b/pylabrobot/plate_reading/agilent/biotek_tests.py index 23ef47a799d..d011901249f 100644 --- a/pylabrobot/plate_reading/agilent/biotek_tests.py +++ b/pylabrobot/plate_reading/agilent/biotek_tests.py @@ -6,6 +6,10 @@ import unittest.mock from typing import Iterator +import pytest + +pytest.importorskip("pylibftdi") + from pylabrobot.plate_reading.agilent.biotek_cytation_backend import CytationBackend from pylabrobot.resources import CellVis_24_wellplate_3600uL_Fb, CellVis_96_wellplate_350uL_Fb diff --git a/pylabrobot/pumps/agrowpumps/agrowdosepump_backend.py b/pylabrobot/pumps/agrowpumps/agrowdosepump_backend.py index db2226e9278..1ba4da80908 100644 --- a/pylabrobot/pumps/agrowpumps/agrowdosepump_backend.py +++ b/pylabrobot/pumps/agrowpumps/agrowdosepump_backend.py @@ -34,6 +34,11 @@ class AgrowPumpArrayBackend(PumpArrayBackend): """ def __init__(self, port: str, address: Union[int, str]): + if _MODBUS_IMPORT_ERROR is not None: + raise RuntimeError( + "pymodbus is not installed. Install with: pip install pylabrobot[modbus]. " + f"Import error: {_MODBUS_IMPORT_ERROR}" + ) if not isinstance(port, str): raise ValueError("Port must be a string") self.port = port diff --git a/pylabrobot/pumps/agrowpumps/agrowdosepump_tests.py b/pylabrobot/pumps/agrowpumps/agrowdosepump_tests.py index 8a8082a2601..b9d6047a0e4 100644 --- a/pylabrobot/pumps/agrowpumps/agrowdosepump_tests.py +++ b/pylabrobot/pumps/agrowpumps/agrowdosepump_tests.py @@ -1,6 +1,10 @@ import unittest from unittest.mock import AsyncMock, call +import pytest + +pytest.importorskip("pymodbus") + from pymodbus.client import AsyncModbusSerialClient # type: ignore from pylabrobot.pumps import PumpArray diff --git a/pylabrobot/pumps/cole_parmer/masterflex_backend.py b/pylabrobot/pumps/cole_parmer/masterflex_backend.py index d94a35e0395..41cdf5b24a0 100644 --- a/pylabrobot/pumps/cole_parmer/masterflex_backend.py +++ b/pylabrobot/pumps/cole_parmer/masterflex_backend.py @@ -1,4 +1,10 @@ -import serial # type: ignore +try: + import serial # type: ignore + + HAS_SERIAL = True +except ImportError as e: + HAS_SERIAL = False + _SERIAL_IMPORT_ERROR = e from pylabrobot.io.serial import Serial from pylabrobot.pumps.backend import PumpBackend @@ -24,6 +30,11 @@ class MasterflexBackend(PumpBackend): """ def __init__(self, com_port: str): + if not HAS_SERIAL: + raise RuntimeError( + "pyserial is not installed. Install with: pip install pylabrobot[serial]. " + f"Import error: {_SERIAL_IMPORT_ERROR}" + ) self.com_port = com_port self.io = Serial( port=self.com_port, diff --git a/pylabrobot/sealing/a4s_backend.py b/pylabrobot/sealing/a4s_backend.py index 9835a8b74aa..5c07cb4a333 100644 --- a/pylabrobot/sealing/a4s_backend.py +++ b/pylabrobot/sealing/a4s_backend.py @@ -4,7 +4,13 @@ import time from typing import Set -import serial +try: + import serial + + HAS_SERIAL = True +except ImportError as e: + HAS_SERIAL = False + _SERIAL_IMPORT_ERROR = e from pylabrobot.io.serial import Serial from pylabrobot.sealing.backend import SealerBackend @@ -12,6 +18,11 @@ class A4SBackend(SealerBackend): def __init__(self, port: str, timeout=20) -> None: + if not HAS_SERIAL: + raise RuntimeError( + "pyserial is not installed. Install with: pip install pylabrobot[serial]. " + f"Import error: {_SERIAL_IMPORT_ERROR}" + ) super().__init__() self.port = port self.timeout = timeout diff --git a/pylabrobot/storage/cytomat/cytomat.py b/pylabrobot/storage/cytomat/cytomat.py index a544d8a6515..ca9227fe003 100644 --- a/pylabrobot/storage/cytomat/cytomat.py +++ b/pylabrobot/storage/cytomat/cytomat.py @@ -4,7 +4,13 @@ import warnings from typing import List, Literal, Optional, Union, cast -import serial +try: + import serial + + HAS_SERIAL = True +except ImportError as e: + HAS_SERIAL = False + _SERIAL_IMPORT_ERROR = e from pylabrobot.io.serial import Serial from pylabrobot.resources import Plate, PlateCarrier, PlateHolder @@ -49,6 +55,11 @@ class CytomatBackend(IncubatorBackend): serial_message_encoding = "utf-8" def __init__(self, model: Union[CytomatType, str], port: str): + if not HAS_SERIAL: + raise RuntimeError( + "pyserial is not installed. Install with: pip install pylabrobot[serial]. " + f"Import error: {_SERIAL_IMPORT_ERROR}" + ) super().__init__() supported_models = [ diff --git a/pylabrobot/storage/cytomat/heraeus_cytomat_backend.py b/pylabrobot/storage/cytomat/heraeus_cytomat_backend.py index bc332679650..a991d55fc59 100644 --- a/pylabrobot/storage/cytomat/heraeus_cytomat_backend.py +++ b/pylabrobot/storage/cytomat/heraeus_cytomat_backend.py @@ -4,7 +4,13 @@ import warnings from typing import List, Tuple -import serial +try: + import serial + + HAS_SERIAL = True +except ImportError as e: + HAS_SERIAL = False + _SERIAL_IMPORT_ERROR = e from pylabrobot.io.serial import Serial from pylabrobot.resources import Plate, PlateHolder @@ -31,6 +37,11 @@ class HeraeusCytomatBackend(IncubatorBackend): poll_interval = 0.2 def __init__(self, port: str): + if not HAS_SERIAL: + raise RuntimeError( + "pyserial is not installed. Install with: pip install pylabrobot[serial]. " + f"Import error: {_SERIAL_IMPORT_ERROR}" + ) super().__init__() self.io = Serial( port=port, diff --git a/pylabrobot/storage/inheco/incubator_shaker_backend.py b/pylabrobot/storage/inheco/incubator_shaker_backend.py index a7248f617fb..2d4a7ace352 100644 --- a/pylabrobot/storage/inheco/incubator_shaker_backend.py +++ b/pylabrobot/storage/inheco/incubator_shaker_backend.py @@ -148,6 +148,11 @@ def __init__( vid: int = 0x0403, pid: int = 0x6001, ): + if not HAS_SERIAL: + raise RuntimeError( + "pyserial is not installed. Install with: pip install pylabrobot[serial]. " + f"Import error: {_SERIAL_IMPORT_ERROR}" + ) super().__init__() self.logger = logging.LoggerAdapter( diff --git a/pylabrobot/storage/liconic/liconic_backend.py b/pylabrobot/storage/liconic/liconic_backend.py index 9f6658a61be..f9b770f727f 100644 --- a/pylabrobot/storage/liconic/liconic_backend.py +++ b/pylabrobot/storage/liconic/liconic_backend.py @@ -5,7 +5,13 @@ import warnings from typing import List, Optional, Tuple, Union -import serial +try: + import serial + + HAS_SERIAL = True +except ImportError as e: + HAS_SERIAL = False + _SERIAL_IMPORT_ERROR = e from pylabrobot.barcode_scanners import BarcodeScanner from pylabrobot.io.serial import Serial @@ -53,6 +59,11 @@ def __init__( port: str, barcode_scanner: Optional[BarcodeScanner] = None, ): + if not HAS_SERIAL: + raise RuntimeError( + "pyserial is not installed. Install with: pip install pylabrobot[serial]. " + f"Import error: {_SERIAL_IMPORT_ERROR}" + ) super().__init__() self.barcode_scanner = barcode_scanner diff --git a/pylabrobot/storage/liconic/liconic_backend_tests.py b/pylabrobot/storage/liconic/liconic_backend_tests.py index 1cbdeea61fa..b3ed09dccfa 100644 --- a/pylabrobot/storage/liconic/liconic_backend_tests.py +++ b/pylabrobot/storage/liconic/liconic_backend_tests.py @@ -3,6 +3,10 @@ import unittest from unittest.mock import AsyncMock +import pytest + +pytest.importorskip("serial") + from pylabrobot.resources import PlateHolder from pylabrobot.resources.carrier import PlateCarrier from pylabrobot.storage.liconic.constants import LiconicType diff --git a/pylabrobot/thermocycling/opentrons_backend_tests.py b/pylabrobot/thermocycling/opentrons_backend_tests.py index e83b5e8c8cf..b883b99b1f4 100644 --- a/pylabrobot/thermocycling/opentrons_backend_tests.py +++ b/pylabrobot/thermocycling/opentrons_backend_tests.py @@ -1,6 +1,10 @@ import unittest from unittest.mock import patch +import pytest + +pytest.importorskip("ot_api") + from pylabrobot.resources.itemized_resource import ItemizedResource from pylabrobot.thermocycling.opentrons import OpentronsThermocyclerModuleV1 from pylabrobot.thermocycling.opentrons_backend import OpentronsThermocyclerBackend diff --git a/pylabrobot/visualizer/visualizer.py b/pylabrobot/visualizer/visualizer.py index f1beba2aa37..42358877444 100644 --- a/pylabrobot/visualizer/visualizer.py +++ b/pylabrobot/visualizer/visualizer.py @@ -121,6 +121,12 @@ def __init__( indicate liquid volume. Default is ``"F39C12"`` (amber). """ + if not HAS_WEBSOCKETS: + raise RuntimeError( + "The visualizer requires websockets to be installed. " + f"Import error: {_WEBSOCKETS_IMPORT_ERROR}" + ) + self.setup_finished = False self._show_machine_tools_at_start = show_machine_tools_at_start color = liquid_color.strip().lstrip("#") @@ -451,11 +457,6 @@ async def _run_ws_server(self): Sets up the websocket server. This will run in a separate thread. """ - if not HAS_WEBSOCKETS: - 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() while True: diff --git a/pylabrobot/visualizer/visualizer_tests.py b/pylabrobot/visualizer/visualizer_tests.py index 8d062d9b108..527c55d2337 100644 --- a/pylabrobot/visualizer/visualizer_tests.py +++ b/pylabrobot/visualizer/visualizer_tests.py @@ -127,7 +127,7 @@ async def asyncTearDown(self): def test_get_index_html(self): """Test that the index.html file is returned.""" - r = urllib.request.urlopen("http://localhost:1337/", timeout=10) + r = urllib.request.urlopen(f"http://localhost:{self.vis.fs_port}/", timeout=10) self.assertEqual(r.status, 200) self.assertIn( r.headers["Content-Type"], diff --git a/pyproject.toml b/pyproject.toml index 5b5c47dd68e..a1225c3cd47 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,21 +19,28 @@ hid = ["hid"] modbus = ["pymodbus>=3.0.0,<3.7.0"] opentrons = ["opentrons-http-api-client==0.2.0"] sila = ["zeroconf>=0.131.0", "grpcio"] -dev = [ - "PyLabRobot[serial,usb,ftdi,hid,modbus,websockets,visualizer,opentrons,sila]", - "numpy", +microscopy = ["numpy>=1.26", "opencv-python"] +pico = ["PyLabRobot[microscopy,sila]"] +all = ["PyLabRobot[serial,usb,ftdi,hid,modbus,websockets,visualizer,opentrons,sila]"] +test = [ "pytest", "pytest-timeout", +] +dev = [ + "PyLabRobot[all,test]", + + # lint/type "mypy==1.18.2", - "sphinx-reredirects", "ruff==0.15.4", + + # docs + "sphinx-reredirects", "nbconvert", "sphinx-sitemap", "pydata-sphinx-theme", "myst_nb", "sphinx_copybutton", ] -all = ["PyLabRobot[dev]"] [project.urls] Homepage = "https://github.com/pylabrobot/pylabrobot"