From d32b3e86da2228f2f5d1779f8ed66d728acb7cdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Weber?= Date: Wed, 7 Oct 2026 00:38:25 +0200 Subject: [PATCH 1/2] Use the plugin acceptance checks provided by PyMoDAQ 5.3.1 Replace the copy of the generic tests of the plugin template by the stub using pymodaq_utils.plugin_testing (see pymodaq_plugins_template PR 43). The module is skipped with older versions of PyMoDAQ. Co-Authored-By: Claude Sonnet 5.5 --- tests/test_plugin_package_structure.py | 148 ++++--------------------- 1 file changed, 21 insertions(+), 127 deletions(-) diff --git a/tests/test_plugin_package_structure.py b/tests/test_plugin_package_structure.py index fb9548a..fd5e389 100644 --- a/tests/test_plugin_package_structure.py +++ b/tests/test_plugin_package_structure.py @@ -1,134 +1,28 @@ -# -*- coding: utf-8 -*- -""" -Created the 17/10/2023 - -@author: Sebastien Weber -""" -import pytest -from pathlib import Path -import importlib -import pkgutil -from collections.abc import Iterable - -from pymodaq_data import Q_, Unit - - -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 - - -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}') +"""Acceptance tests of the plugin, provided by PyMoDAQ (from version 5.3.1). - 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') - - -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}') - - -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) - - -def test_move_has_correct_units(): - 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}') - if not isinstance(klass._controller_units, list): - if isinstance(klass._controller_units, dict): - units = list(klass._controller_units.values()) - elif isinstance(klass._controller_units, str): - units = [klass._controller_units] - else: - raise TypeError(f'{klass._controller_units} is an invalid type') - else: - units = klass._controller_units - for unit in units: - Unit(unit) # check if the unit is known from pint +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). +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 -@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) +import pytest -def test_compatibility(capsys): - capsys.disabled() - try: - from pymodaq_plugin_manager.compatibility_checker import PyMoDAQPlugin - except (ModuleNotFoundError, ImportError) as e: - pytest.fail(f"Please update pymodaq_plugin_manager to a newer version: {e}") +try: + available = importlib.util.find_spec('pymodaq_utils.plugin_testing') is not None +except ModuleNotFoundError: # pymodaq_utils is not installed + available = False +if not available: + pytest.skip('The plugin acceptance checks need PyMoDAQ >= 5.3.1', allow_module_level=True) - plugin = PyMoDAQPlugin(get_package_name(), None) - success = plugin.all_imports_valid() - msg = '\n'.join(plugin._failed_imports + ['']) +from pymodaq_utils.plugin_testing import PluginPackageChecks - if not success: - plugin.save_import_report(".") - assert success, msg \ No newline at end of file +class TestPlugin(PluginPackageChecks): + package_name = 'pymodaq_plugins_template' # the package of this repository was not renamed + 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 From ed7f7a4f8f52a60b3d5e848c800d50d7a53e64e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 17:08:09 +0000 Subject: [PATCH 2/2] Follow the plugin template: acceptance stub from pymodaq.utils.plugin_testing, shared workflows, AGENTS.md Remove the copies of the workflows (Test.yml, Testbase.yml, updater.yml) and call the reusable ones of pymodaq_plugins_template; add AGENTS.md and CLAUDE.md for AI coding assistants. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Tdiso1u1ZhikSutj2Qs5Cg --- .github/workflows/Test.yml | 10 -- .github/workflows/Testbase.yml | 44 ------ .github/workflows/compatibility.yml | 73 +--------- .github/workflows/python-publish.yml | 37 +---- .github/workflows/tests.yml | 8 ++ .github/workflows/updater.yml | 24 ---- AGENTS.md | 180 +++++++++++++++++++++++++ CLAUDE.md | 3 + tests/test_plugin_package_structure.py | 12 +- 9 files changed, 206 insertions(+), 185 deletions(-) delete mode 100644 .github/workflows/Test.yml delete mode 100644 .github/workflows/Testbase.yml create mode 100644 .github/workflows/tests.yml delete mode 100644 .github/workflows/updater.yml create mode 100644 AGENTS.md create mode 100644 CLAUDE.md 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 2d00e2f..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@v5.0.0 - - name: Install dependencies - uses: actions/setup-python@v6.0.0 - 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 -k "not test_compatibility" diff --git a/.github/workflows/compatibility.yml b/.github/workflows/compatibility.yml index 01003f5..3779c46 100644 --- a/.github/workflows/compatibility.yml +++ b/.github/workflows/compatibility.yml @@ -1,78 +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: - workflow_call: - pull_request: - push: - branches: - - '*' + branches: [main, master, '[0-9]*.x'] concurrency: - # github.workflow: name of the workflow - # github.event.pull_request.number || github.ref: pull request number or branch name if not a pull request group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - # Cancel in-progress runs when a new workflow with the same group name is triggered cancel-in-progress: true - -jobs: - tests: - continue-on-error: true - strategy: - fail-fast: false - matrix: - os: ["ubuntu-latest", "windows-latest"] - python-version: ["3.9", "3.10", "3.11", "3.12"] - qt-backend: ["pyqt5", "pyqt6", "pyside6"] - runs-on: ${{ matrix.os }} - env: - DISPLAY: ':99' - QT_DEBUG_PLUGINS: 1 - - steps: - - name: Set project name environment variable - run: | - echo "plugin_name=$(echo '${{ github.repository }}' | cut -d'/' -f2)" >> $GITHUB_ENV - - name: Checkout the repo - uses: actions/checkout@v5.0.0 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6.0.0 - with: - python-version: ${{ matrix.python-version }} - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install flake8 pytest pytest-cov pytest-qt pytest-xvfb pytest-xdist setuptools wheel numpy h5py pymodaq ${{ matrix.qt-backend }} - pip install -e . - - # Create folder and set permissions on Ubuntu - - name: Create local pymodaq folder setup env (Linux) - if: runner.os == 'Linux' - run: | - sudo apt update - sudo apt install -y libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-cursor0 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 x11-utils libgl1 libegl1 - export QT_DEBUG_PLUGINS=1 - sudo mkdir -p /etc/.pymodaq - sudo chmod uo+rw /etc/.pymodaq - - name: Exporting debug variables (Windows) - if: runner.os == 'Windows' - run: | - set QT_DEBUG_PLUGINS=1 - - - name: Compatibility tests with ${{ matrix.os }} ${{ matrix.python-version }} ${{ matrix.qt-backend}} - run: | - pytest -vv -n 1 -k "test_compatibility" - - - name: Upload compatibility report - if: failure() - uses: actions/upload-artifact@v4.6.2 - with: - name: - path: 'import_report_tests_${{ env.plugin_name }}_None.txt' - if-no-files-found: error - - \ No newline at end of file +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 67c4a59..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@v5.0.0 - - name: Set up Python - uses: actions/setup-python@v6.0.0 - 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 8c60cea..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@v5.0.0 - 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/tests/test_plugin_package_structure.py b/tests/test_plugin_package_structure.py index fd5e389..a7bdf2b 100644 --- a/tests/test_plugin_package_structure.py +++ b/tests/test_plugin_package_structure.py @@ -1,4 +1,4 @@ -"""Acceptance tests of the plugin, provided by PyMoDAQ (from version 5.3.1). +"""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 @@ -12,17 +12,17 @@ import pytest try: - available = importlib.util.find_spec('pymodaq_utils.plugin_testing') is not None -except ModuleNotFoundError: # pymodaq_utils is not installed + 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.1', allow_module_level=True) + pytest.skip('The plugin acceptance checks need PyMoDAQ >= 5.3.2', allow_module_level=True) -from pymodaq_utils.plugin_testing import PluginPackageChecks +from pymodaq.utils.plugin_testing import PluginPackageChecks class TestPlugin(PluginPackageChecks): - package_name = 'pymodaq_plugins_template' # the package of this repository was not renamed + # 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