diff --git a/.github/workflows/Test.yml b/.github/workflows/Test.yml deleted file mode 100644 index ffd1522..0000000 --- a/.github/workflows/Test.yml +++ /dev/null @@ -1,10 +0,0 @@ -name: 'Tests' - -on: [push] - -jobs: - call_workflow: - uses: ./.github/workflows/Testbase.yml - with: - python: '3.11' - qt5: 'pyqt5' \ No newline at end of file diff --git a/.github/workflows/Testbase.yml b/.github/workflows/Testbase.yml deleted file mode 100644 index 989a2a0..0000000 --- a/.github/workflows/Testbase.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Base - -on: - workflow_call: - inputs: - python: - required: true - type: string - qt5: - required: true - type: string - -jobs: - build: - runs-on: ubuntu-latest - env: - DISPLAY: ':99.0' - QT_DEBUG_PLUGINS: 1 - steps: - - name: Set up Python ${{ inputs.python }} - uses: actions/checkout@v4 - - name: Install dependencies - uses: actions/setup-python@v4 - with: - python-version: ${{ inputs.python }} - - name: Install package - run: | - sudo apt install libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 x11-utils - python -m pip install --upgrade pip - export QT_DEBUG_PLUGINS=1 - pip install flake8 pytest pytest-cov pytest-qt pytest-xdist pytest-xvfb setuptools wheel numpy h5py ${{ inputs.qt5 }} toml - pip install pymodaq pyqt5 - pip install -e . - - name: create local pymodaq folder and setting permissions - run: | - sudo mkdir /etc/.pymodaq - sudo chmod uo+rw /etc/.pymodaq - - name: Linting with flake8 - run: | - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics --exclude=src/pymodaq/resources/QtDesigner_Ressources,docs - - name: Test with pytest - run: | - pytest -n auto diff --git a/.github/workflows/compatibility.yml b/.github/workflows/compatibility.yml new file mode 100644 index 0000000..3779c46 --- /dev/null +++ b/.github/workflows/compatibility.yml @@ -0,0 +1,15 @@ +# Everything is defined once in PyMoDAQ/pymodaq_plugins_template (reusable-*.yml). The template is never released, so the ref after @ is its 5.3.x branch. +name: Compatibility with pymodaq (latest release) + +on: + pull_request: + push: + branches: [main, master, '[0-9]*.x'] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + compatibility: + uses: PyMoDAQ/pymodaq_plugins_template/.github/workflows/reusable-compatibility.yml@5.3.x diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 69806c5..2b1d214 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -1,6 +1,4 @@ -# This workflow will upload a Python Package using Twine when a release is created -# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries - +# Everything is defined once in PyMoDAQ/pymodaq_plugins_template (reusable-*.yml). The template is never released, so the ref after @ is its 5.3.x branch. name: Upload Python Package on: @@ -8,33 +6,6 @@ on: types: [created] jobs: - deploy: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.x' - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install hatch hatchling toml twine - - name: Get history and tags for SCM versioning to work - run: | - git branch - git fetch --prune --unshallow - git fetch --depth=1 origin +refs/tags/*:refs/tags/* - hatch version - - name: Build - run: hatch build - - name: Check the build - run: twine check dist/* - - name: publish - env: - HATCH_INDEX_USER: ${{ secrets.PYPI_USERNAME }} - HATCH_INDEX_AUTH: ${{ secrets.PYPI_PASSWORD }} - run: | - hatch publish + publish: + uses: PyMoDAQ/pymodaq_plugins_template/.github/workflows/reusable-publish.yml@5.3.x + secrets: inherit diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..4291bb9 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,8 @@ +# Everything is defined once in PyMoDAQ/pymodaq_plugins_template (reusable-*.yml). The template is never released, so the ref after @ is its 5.3.x branch. +name: Tests + +on: [push, pull_request] + +jobs: + tests: + uses: PyMoDAQ/pymodaq_plugins_template/.github/workflows/reusable-tests.yml@5.3.x diff --git a/.github/workflows/updater.yml b/.github/workflows/updater.yml deleted file mode 100644 index f2d0fcd..0000000 --- a/.github/workflows/updater.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: GitHub Actions Version Updater - -# Controls when the action will run. -on: - workflow_dispatch: - schedule: - # Automatically run at 00:00 on day-of-month 5. - - cron: '0 0 5 * *' - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4.2.2 - with: - # [Required] Access token with `workflow` scope. - token: ${{ secrets.WORKFLOW_SECRET }} - - - name: Run GitHub Actions Version Updater - uses: saadmk11/github-actions-version-updater@v0.9.0 - with: - # [Required] Access token with `workflow` scope. - token: ${{ secrets.WORKFLOW_SECRET }} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..f0e82c3 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,180 @@ +# AGENTS.md + +Guidance for AI coding assistants (and humans) building on **PyMoDAQ 5.3.x** from this template. +PyMoDAQ is a Qt framework that drives lab experiments: hardware is added through small *plugins*, and PyMoDAQ +provides the GUI, threading, data model, scans and HDF5 saving. Python 3.10 to 3.13. + +This repository is a plugin package named `pymodaq_plugins_`. Rename everything that still says `template` +before publishing. + +## 1. Choose what to build + +Prefer the first option that fits: + +1. **Instrument plugin** (`daq_move_plugins/`, `daq_viewer_plugins/`): a new device must appear in the Dashboard. +2. **Dashboard extension** (`extensions/`, class derived from `CustomExt`): a workflow that uses instruments + (scans, feedback, custom panels, logging). It runs inside the Dashboard and reuses its stable mechanisms for + instruments: the experiment manager and the state manager (how a Dashboard setup is described, loaded and + restored; the word "preset" is obsolete), the module manager, control-module threading, shared controllers, + saving. **If the application drives instruments, build a `CustomExt`, not a `CustomApp`.** +3. **Standalone app** (`app/`, class derived from `CustomApp`): only when the application needs **no + instruments** (data viewer, analysis, h5 browsing, configuration tools). +4. A plain script, for a one-off sequence that does not need a GUI. + +## 2. Workflow + +```bash +pip install -e . # editable install of this plugin, from the repository root +check_plugin # static report on this package (hardware not needed) +check_plugin --fail-on todo -v # also list every unfinished TODO +pytest # template tests (package structure, plugin checks, behaviour against a fake controller) +``` + +Installing PyMoDAQ itself is a separate matter. A plugin only needs the released packages (`pymodaq`, +`pymodaq_utils`, `pymodaq_data`, `pymodaq_gui`, installed as dependencies). If you also have to modify them, work in a +clone of the PyMoDAQ repository and install the four packages in editable mode with the script at its root, never with +`pip install -e .` inside each package (the script handles the order and the options): + +```bash +python install-packages.py -d # editable + dev extras, from the PyMoDAQ repository root +python install-packages.py -d -qt pyside6 # choose the Qt backend (pyqt5, pyqt6 or pyside6) +``` + +Do not consider a task finished until `check_plugin` and `pytest` pass. Do not invent PyMoDAQ APIs: if a method +or attribute is not in the base classes (`DAQ_Move_base`, `DAQ_Viewer_base`, `CustomExt`) or in an existing +plugin, look it up in the installed `pymodaq` package instead of guessing. + +## 3. Instrument plugins + +File and class names must match: `daq_move_.py` holds `DAQ_Move_`; `daq_Dviewer_.py` holds +`DAQ_DViewer_` (N = 0, 1, 2 or N). Each file ends with `if __name__ == '__main__': main(__file__)`. + +**Actuator** (`DAQ_Move_base`). Override at least, in the order of the template (the order in which to develop them), +`ini_attributes`, `ini_stage`, `close`, `get_actuator_value`, `move_abs`, `move_rel`, `move_home`, `stop_motion`, +`commit_settings`. + +- `ini_stage(controller=None)` returns `(info: str, initialized: bool)`. If `self.is_master`, create the controller; + otherwise store the `controller` passed in (several axes share one controller). +- Class attributes: `is_multiaxes`, `_axis_names`, `_controller_units`, `_epsilons`, + `data_actuator_type = DataActuatorType.DataActuator`, optionally `ui_type` and `has_encoder`. Always write the + `data_actuator_type` line: the default of the base class is `DataActuatorType.float`, a backcompatibility mode where + `move_abs`/`move_rel` receive a plain float (already in the axis unit) and `get_actuator_value` returns one. PyMoDAQ + guards that case, but do not use it in new code. +- `params` = list of dicts + `comon_parameters_fun(is_multiaxes, axis_names=..., epsilon=...)`. +- Positions are `DataActuator` objects with **units** (`units=self.axis_unit`); use `check_bound`, + `set_position_with_scaling` and `get_position_with_scaling` as in the template. + +**Detector** (`DAQ_Viewer_base`). Override at least, in the order of the template, `ini_attributes`, `ini_detector`, +`close`, `grab_data`, `stop`, `commit_settings`. + +- `ini_detector(controller=None)` returns `(info, initialized)`, same master/slave logic. +- `params = comon_parameters + [...]`. +- Data leaves the plugin as `DataToExport` containing `DataFromPlugins` (with `dim='Data0D'|'Data1D'|'Data2D'|'DataND'`, + `labels`, `axes=[Axis(...)]`) emitted through `self.dte_signal` (grab) or `self.dte_signal_temp` (live preview). + +**Rules that matter** + +- Plugin code runs in a worker thread. **Never create or touch Qt widgets** from it. Report progress with + `self.emit_status(ThreadCommand('Update_Status', ['message']))`. +- Do not block for long inside `grab_data` or a move; use the asynchronous pattern shown in the template and the + polling that PyMoDAQ already does (`epsilon`). +- Always release the hardware in `close()`, only if `self.is_master and self.controller is not None` (the init may have + failed before the controller existed). Leave actuators in a safe state on error. +- Settings are `pyqtgraph` Parameter dicts (`{'title', 'name', 'type', 'value', ...}`). React to changes in + `commit_settings(param)` by `param.name()`. +- Units are `pint`-based. Give every actuator value and every `Axis` a unit; mismatches raise `DataUnitError`. + What you send to a controller must be expressed in the controller units: use `value.value(self.axis_unit)` in + `move_abs` / `move_rel`, never `value.value()` (the magnitude in whatever unit the `DataActuator` carries, so 1 cm + would be sent as 1.0 to a controller in mm). `axis_unit` is the unit of the current axis, `axis_units` the list/dict + of all the axes of a multiaxes controller. +- Hardware access code goes in `hardware/`, behind a small wrapper class. This lets the plugin be tested with a + mock controller and keeps the plugin file readable. +- Defaults live in `resources/config_template.toml`, not in the code. Read configuration values through + `GlobalConfig` from `pymodaq_utils.config`: a singleton that wraps the `Config` objects of all packages, e.g. + `GlobalConfig()('gui', 'style', 'theme')`. Do not parse the toml files yourself, and do not instantiate a `Config` + class directly (deprecated): PyMoDAQ registers the `Config` of `/utils.py` in `GlobalConfig` when it + discovers the plugin, under the package name without `pymodaq_plugins_`. + +**Testing behaviour without hardware.** `pymodaq.utils.plugin_testing` (shipped with PyMoDAQ >= 5.3.2) drives a plugin the way PyMoDAQ does, without a GUI: +`make_actuator` / `make_detector` (instantiate and call `ini_stage` / `ini_detector`), `move_abs_and_wait`, +`move_rel_and_wait`, `move_home_and_wait`, `grab_and_wait` (return the final position or the `DataToExport`, and fail +on a timeout) and `assert_units`. `tests/example_mock_plugins.py` shows a fake controller and the smallest actuator, +multi-axes actuator and 1D detector; `tests/test_plugin_behaviour.py` shows the tests to copy. Write the vendor +communication behind a wrapper class in `hardware/`, write a fake with the same public methods, and run your real +plugin class against it (monkeypatch the wrapper class). Targets reach the plugin in the axis unit, as in PyMoDAQ. + +These tests only check your side of the PyMoDAQ contract (return types, units, signals, shared controller, `close`) +against a fake that encodes what you believe the driver does. They say nothing about the device: timing, error replies, +real ranges and units. A passing run is not a hardware validation: never state that a plugin works on an instrument +that was not used, say what was tested on the real device and what only against a fake. + +**Legacy patterns to avoid** (older tutorials and models still produce them; `check_plugin` flags several): +`stage_names` (use `_axis_names`), `_epsilon` (use `_epsilons`), `data_actuator_type = float` (use +`DataActuatorType.DataActuator`), the group names `multiaxes` and `multi_status` (now `controller` and +`controller_status`), importing `CustomExt` from `pymodaq.extensions.custom_ext` (use `pymodaq.utils.custom_ext`), +and the deprecated `CustomApp` / `CustomExt` hooks `setup_docks` and `setup_menu` (use `setup_docks_and_widgets` and +`setup_menus_and_toolbars`). + +## 4. Dashboard extensions (`CustomExt`) + +- Entry point group `pymodaq.extensions` in `pyproject.toml`; each module in `extensions/` defines + `EXTENSION_NAME` (menu label), `CLASS_NAME` (class name) and a class derived from `CustomExt`. +- Import: `from pymodaq.utils.custom_ext import CustomExt`. +- A plugin extension follows exactly the pattern of the core extensions (`daq_scan`, `sequencer`, `ramping`, data + mixer). The only difference is `EXTENSION_NAME` and `CLASS_NAME`, which the Dashboard needs to recognise it. +- **`_quit_fun(self) -> bool` must be overridden and return `True`** (return `False`, after telling the user, while + the extension is running). The base implementation returns `None`, which prevents closing. +- **`main()` pattern** (to run the extension alone, same as the core ones): + + ```python + def main(): + import sys + from pymodaq_gui.qt_utils import mkQApp + from pymodaq.dashboard import load_dashboard_with_arguments + from pymodaq.utils.gui_utils.loader_utils import create_extension + + app = mkQApp('My Extension') + win, dashboard, _ = load_dashboard_with_arguments(show_dashboard=False, load_extension=False) + win.mainwindow.setVisible(False) + win_ext, ext = create_extension(dashboard, MyExtension, show_extension=True) + sys.exit(app.exec()) + ``` + + `load_dashboard_with_arguments` reads the command line (`-x EXPERIMENT_NAME`, `-s STATE_NAME`). + `create_extension` already shows the window: do not call `win_ext.show()` again, and do not use `create_load_dashboard` + here. +- Long-running work (a scan, a sequence, a ramp) goes in an `ExtensionWorker` subclass run in a thread, started and + stopped from the extension's workflow actions, as in `sequencer.py` and `ramping.py`. +- `__init__(self, parent: DockArea, dashboard)`: call `super().__init__(parent, dashboard)`, then `self.setup_ui()`. +- **Experiment and state belong to the Dashboard.** The *experiment* is the list of instruments (actuators and + detectors) the Dashboard manages and controls; the *state* is the state of that experiment, mostly the values of the + instruments' settings and of the Dashboard's actuators. An extension must use the managers it gets through its + `dashboard` argument, as `self.experiment_manager` and `self.state_manager`, and never create its own, nor its own + save/load of instrument configurations. Override `do_things_after_experiment_set(experiment_name, show_dashboard)` + to react when an experiment is set (the base class refreshes `self.modules_manager` there), and use + `create_dashboard_toolbar(...)` to expose the experiment and state actions in your extension. +- The Dashboard's instruments are reached through `self.modules_manager`. This `ModulesManager` is created anew for + each extension by the base class `__init__`, so that every extension can handle different instruments: never share + it between extensions, replace it, or reuse the Dashboard's one. Do not create control modules yourself inside an + extension, and do not talk to the hardware directly. +- Implement the lifecycle, in the order `setup_ui` calls it: `setup_docks_and_widgets`, `setup_menus_and_toolbars` + (only creates the menus and toolbars), `setup_actions` (creates the actions and can put them in those menus and + toolbars with `add_action(..., menu=..., toolbar=...)`), `connect_things`, then `value_changed`, `quit_fun`. `setup_docks` and `setup_menu` are deprecated. The GUI thread runs + your code; react to module signals, never poll or sleep. + +## 5. Standalone apps (`CustomApp`) + +Only when no instrument is needed. Same (non-deprecated) lifecycle as above, `parent` is a `DockArea` or `QMainWindow`, and +`main()` creates the app with `mkQApp`. If instruments become necessary, move the logic to a `CustomExt`. + +## 6. Done checklist + +- [ ] Names (package, files, classes) match and the `template` placeholders are gone. +- [ ] The `[features]` flags at the top of `pyproject.toml` match the folders you use (`instruments`, `extensions`, + `models`...): entry points are generated only for the features set to `true` (`check_plugin` warns, PMQ109). +- [ ] `check_plugin` passes; no `NotImplementedError` or `TODO` left in the files you wrote. +- [ ] Units set on all actuator values and axes; safe behaviour in `close()` and on errors. +- [ ] Tests added for new behaviour using a mock controller (no real hardware in CI). +- [ ] Docstring of each plugin class states: compatible devices, what was tested, PyMoDAQ and OS versions, + drivers to install. +- [ ] `README.rst` updated; target PyMoDAQ version stated. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..bf683e0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,3 @@ +# CLAUDE.md + +Read and follow [AGENTS.md](AGENTS.md): it holds all the guidance for AI coding assistants working in this repository. diff --git a/pyproject.toml b/pyproject.toml index 2e44144..22f20c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,10 @@ package-url = 'https://github.com/PyMoDAQ/pymodaq_plugins_montana' name = "pymodaq_plugins_montana" description = 'Plugin for instruments from Montana: Cryostat, positioner (rook)...' dependencies = [ - "pymodaq>=5.0.0", + "pymodaq>=5.3.2", + "pymodaq_utils>=5.3.1", + "pymodaq_gui>=5.3.0", + "pymodaq_data>=5.3.0", "sshtunnel", ] diff --git a/src/pymodaq_plugins_montana/hardware/TcPlot.py b/src/pymodaq_plugins_montana/hardware/TcPlot.py deleted file mode 100644 index 61f2b2c..0000000 --- a/src/pymodaq_plugins_montana/hardware/TcPlot.py +++ /dev/null @@ -1,160 +0,0 @@ -#!/usr/bin/env python3 - -"""Routines to perform real-time plotting of data from a temperature controller""" - -import matplotlib.pyplot as plt -import matplotlib.animation as animation -from matplotlib.ticker import FormatStrFormatter -import time -import datetime as dt - -_line_styles = ['r-', - 'b-', - 'g-', - 'c-', - 'm-', - 'y-', - 'k-'] - -class TcPlot(): - def __init__(self, heater_names, thermometer_names, duration=120): - """ - - Keyword Parameters: - heater_names - thermometer_names - duration (in seconds) - """ - assert (len(heater_names)+len(thermometer_names)) <= len(_line_styles), f"We don't have enough colors for {len(samples)} samples. Maximum is {len(_line_styles)}." - - self.heater_names = heater_names - self.thermometer_names = thermometer_names - self.x = [] - self.ys = [[]] * (len(heater_names) + len(thermometer_names)) - - self.Span = duration # timespan to plot (in seconds) - self.PaintInterval = 1.0 # period between plot updates (in seconds) - self.Closed = False # has user closed the plot window? - self.OnClosed = None # callback function called on window close. - - # Create the plot - self.fig, self.ax1 = plt.subplots() - - # Hook the close click event - self.fig.canvas.mpl_connect('close_event', self.handle_close) - - # Enable interactive mode - plt.ion() - - # Init primary axis - self.ax1.set_xlabel('time (s)') - self.ax1.set_ylabel('Heater (W)', color='r') - self.ax1.yaxis.set_major_formatter(FormatStrFormatter('%.4f')) - self.ax1.tick_params('y', colors='r') - self.ax1.grid(color='r', linestyle=':', linewidth=1) - - # Init secondary axis - self.ax2 = self.ax1.twinx() - self.ax2.set_ylabel('Temp (K)', color='b') - self.ax2.yaxis.set_major_formatter(FormatStrFormatter('%.4f')) - self.ax2.tick_params('y', colors='b') - self.ax2.grid(color='b', linestyle=':', linewidth=1) - - # Create a line for each heater and sensor - self.lines = [None] * len(self.ys) - for i in range(len(self.lines)): - self.lines[i], = self._axis(i).plot(self.x, self.ys[i], _line_styles[i]) - - # Add a legend to the plot. - labels = [f"{h} (W)" for h in self.heater_names]+[f"{t} (K)" for t in self.thermometer_names] - plt.legend(self.lines, labels, loc='upper left') - - self.clear() - plt.show() - - self.lastPaintTime = dt.datetime.now() - - def _axis(self, i): - """Lookup which axis to plot the i'th line onto. - - Heaters plot on the first axis, thermometers plot on the second. - - Keyword Parameters: - i index into the lines array. - """ - ax = self.ax1 - if i >= len(self.heater_names): ax = self.ax2 - return ax - - - def setWindowTitle(self, title): - self.fig.canvas.setWindowTitle(title) - - def handle_close(self, evt): - # Indicate the plot has been closed - self.Closed = True - - # Call event handler, if set - if self.OnClosed is not None: - self.OnClosed() - - def clear(self): - - # Remove all existing data - self.x = [] - self.ys = [[]] * len(self.ys) - - # Capture the start time to calculate elapsed time later within update() - self.start = dt.datetime.now() - - def update(self, heaters, thermometers): - """y1 = Heater power, y2 = Temperature""" - - samples = heaters + thermometers - - assert len(samples) == len(self.ys), f"Must provide a total of {len(samples)} samples." - - if self.Closed: - return - - # Capture the total elapsed time since the plot has been started - n = dt.datetime.now() - deltaT = (n - self.start) - elapsed = deltaT.total_seconds() - - # Capture the minimum timestamp of data allowed to remain within the plot - minsec = elapsed - self.Span - - # Delete all data that is too old - while len(self.x) > 0 and self.x[0] < minsec: - self.x = self.x[1:] - for i in range(len(self.ys)): - self.ys[i] = self.ys[i][1:] - - # Add the new samples - self.x.append(elapsed) - for i in range(len(self.ys)): - self.ys[i] = self.ys[i] + [samples[i]] - - # Calc the time elasped since the last paint of the plot - elapsed = (dt.datetime.now() - self.lastPaintTime).total_seconds() - - if elapsed >= self.PaintInterval: - # Repaint the plot - - self.lastPaintTime = dt.datetime.now() - - for i in range(len(self.lines)): - self.lines[i].set_xdata(self.x) - self.lines[i].set_ydata(self.ys[i]) - - self.ax1.relim() - self.ax1.autoscale_view() - - self.ax2.relim() - self.ax2.autoscale_view() - - # Redraw - self.fig.canvas.draw() - plt.pause(0.001) - diff --git a/src/pymodaq_plugins_montana/hardware/instrument.py b/src/pymodaq_plugins_montana/hardware/instrument.py index f11a1a2..147437d 100644 --- a/src/pymodaq_plugins_montana/hardware/instrument.py +++ b/src/pymodaq_plugins_montana/hardware/instrument.py @@ -126,7 +126,7 @@ def _rewrite_connection(ip, port, tunnel): False) except mirs_helpers.MirsClientNotFound: pass - elif _force_ipv4 and not re.match('[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$', ip): + elif _force_ipv4 and not re.match('[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}$', ip): ip = socket.getaddrinfo(ip, None, socket.AF_INET)[0][4][0] return (ip, port, tunnel) diff --git a/tests/test_plugin_package_structure.py b/tests/test_plugin_package_structure.py index 1c44804..a7bdf2b 100644 --- a/tests/test_plugin_package_structure.py +++ b/tests/test_plugin_package_structure.py @@ -1,97 +1,28 @@ -# -*- coding: utf-8 -*- -""" -Created the 17/10/2023 - -@author: Sebastien Weber -""" -import pytest -from pathlib import Path -import importlib -import pkgutil - - -MANDATORY_MOVE_METHODS = ['ini_attributes', 'get_actuator_value', 'close', 'commit_settings', - 'ini_stage', 'move_abs', 'move_home', 'move_rel', 'stop_motion'] -MANDATORY_VIEWER_METHODS = ['ini_attributes', 'grab_data', 'close', 'commit_settings', - 'ini_detector', ] - - -def get_package_name(): - here = Path(__file__).parent - package_name = here.parent.stem - return package_name - -def get_move_plugins(): - pkg_name = get_package_name() - try: - move_mod = importlib.import_module(f'{pkg_name}.daq_move_plugins') - plugin_list = [mod for mod in [mod[1] for mod in - pkgutil.iter_modules([str(move_mod.path.parent)])] - if 'daq_move_' in mod] - except ModuleNotFoundError: - plugin_list = [] - move_mod = None - return plugin_list, move_mod +"""Acceptance tests of the plugin, provided by PyMoDAQ (from version 5.3.2). +They check the package structure, the entry points, the naming of the instrument modules and classes, the mandatory +attributes and methods of the plugin classes, and apply the static rules of PyMoDAQ on the sources +(see https://pymodaq.cnrs.fr/en/latest/developer_folder/instrument_plugins.html#testing-your-plugin). -def get_viewer_plugins(dim='0D'): - pkg_name = get_package_name() - try: - viewer_mod = importlib.import_module(f'{pkg_name}.daq_viewer_plugins.plugins_{dim}') - - plugin_list = [mod for mod in [mod[1] for mod in - pkgutil.iter_modules([str(viewer_mod.path.parent)])] - if f'daq_{dim}viewer_' in mod] - except ModuleNotFoundError: - plugin_list = [] - viewer_mod = None - return plugin_list, viewer_mod - - -def test_package_name_ok(): - assert 'pymodaq_plugins_' in get_package_name()[0:16] - - -def test_imports(): - pkg_name = get_package_name() - mod = importlib.import_module(pkg_name) - assert hasattr(mod, 'config') - assert hasattr(mod, '__version__') - move_mod = importlib.import_module(f'{pkg_name}', 'daq_move_plugins') - importlib.import_module(f'{pkg_name}', 'daq_viewer_plugins') - importlib.import_module(f'{pkg_name}', 'extensions') - importlib.import_module(f'{pkg_name}', 'models') - importlib.import_module(f'{pkg_name}.daq_viewer_plugins', 'plugins_0D') - importlib.import_module(f'{pkg_name}.daq_viewer_plugins', 'plugins_1D') - importlib.import_module(f'{pkg_name}.daq_viewer_plugins', 'plugins_2D') - importlib.import_module(f'{pkg_name}.daq_viewer_plugins', 'plugins_ND') - +The unfinished parts of the plugin (TODO comments, placeholders) are listed but do not fail the tests, set +``fail_on = 'todo'`` before a release. Add your own tests, specific to your instruments, in other files. +""" +import importlib.util -def test_move_inst_plugins_name(): - plugin_list, move_mod = get_move_plugins() - for plug in plugin_list: - name = plug.split('daq_move_')[1] - assert hasattr(getattr(move_mod, plug), f'DAQ_Move_{name}') +import pytest +try: + available = importlib.util.find_spec('pymodaq.utils.plugin_testing') is not None +except ModuleNotFoundError: # pymodaq is not installed + available = False +if not available: + pytest.skip('The plugin acceptance checks need PyMoDAQ >= 5.3.2', allow_module_level=True) -def test_move_has_mandatory_methods(): - plugin_list, move_mod = get_move_plugins() - for plug in plugin_list: - name = plug.split('daq_move_')[1] - klass = getattr(getattr(move_mod, plug), f'DAQ_Move_{name}') - for meth in MANDATORY_MOVE_METHODS: - assert hasattr(klass, meth) +from pymodaq.utils.plugin_testing import PluginPackageChecks -@pytest.mark.parametrize('dim', ('0D', '1D', '2D', 'ND')) -def test_viewer_has_mandatory_methods(dim): - plugin_list, mod = get_viewer_plugins(dim) - for plug in plugin_list: - name = plug.split(f'daq_{dim}viewer_')[1] - try: - module = importlib.import_module(f'.{plug}', mod.__package__) - except Exception: - break - klass = getattr(module, f'DAQ_{dim}Viewer_{name}') - for meth in MANDATORY_VIEWER_METHODS: - assert hasattr(klass, meth) +class TestPlugin(PluginPackageChecks): + # package_name = 'pymodaq_plugins_xxxx' # only if it cannot be found from the pyproject.toml and the package folder + fail_on = 'error' # 'error', 'warning' or 'todo' (also fail on the unfinished parts) + strict_imports = False # True: also fail when a module cannot be imported (missing dependency or driver); the + # modules of an instrument whose driver cannot be installed on the CI runner are then skipped, not failed