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 645f962..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.2.2 - - name: Install dependencies - uses: actions/setup-python@v5.4.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 7c601f5..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@v4.2.2 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5.4.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.1 - 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 7e31581..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.2.2 - - name: Set up Python - uses: actions/setup-python@v5.4.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 4cdbca7..0000000 --- a/.github/workflows/updater.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: GitHub Actions Version Updater - -# Controls when the action will run. -on: - 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.8.1 - 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/src/pymodaq_plugins_princeton_instruments/daq_move_plugins/daq_move_Template.py b/src/pymodaq_plugins_princeton_instruments/daq_move_plugins/daq_move_Template.py deleted file mode 100644 index 3dafa38..0000000 --- a/src/pymodaq_plugins_princeton_instruments/daq_move_plugins/daq_move_Template.py +++ /dev/null @@ -1,197 +0,0 @@ - -from typing import Union, List, Dict -from pymodaq.control_modules.move_utility_classes import (DAQ_Move_base, comon_parameters_fun, - main, DataActuatorType, DataActuator) - -from pymodaq_utils.utils import ThreadCommand # object used to send info back to the main thread -from pymodaq_gui.parameter import Parameter - - -class PythonWrapperOfYourInstrument: - # TODO Replace this fake class with the import of the real python wrapper of your instrument - pass - -# TODO: -# (1) change the name of the following class to DAQ_Move_TheNameOfYourChoice -# (2) change the name of this file to daq_move_TheNameOfYourChoice ("TheNameOfYourChoice" should be the SAME -# for the class name and the file name.) -# (3) this file should then be put into the right folder, namely IN THE FOLDER OF THE PLUGIN YOU ARE DEVELOPING: -# pymodaq_plugins_my_plugin/daq_move_plugins -class DAQ_Move_Template(DAQ_Move_base): - """ Instrument plugin class for an actuator. - - This object inherits all functionalities to communicate with PyMoDAQ’s DAQ_Move module through inheritance via - DAQ_Move_base. It makes a bridge between the DAQ_Move module and the Python wrapper of a particular instrument. - - TODO Complete the docstring of your plugin with: - * The set of controllers and actuators that should be compatible with this instrument plugin. - * With which instrument and controller it has been tested. - * The version of PyMoDAQ during the test. - * The version of the operating system. - * Installation instructions: what manufacturer’s drivers should be installed to make it run? - - Attributes: - ----------- - controller: object - The particular object that allow the communication with the hardware, in general a python wrapper around the - hardware library. - - # TODO add your particular attributes here if any - - """ - is_multiaxes = False # TODO for your plugin set to True if this plugin is controlled for a multiaxis controller - _axis_names: Union[List[str], Dict[str, int]] = ['Axis1', 'Axis2'] # TODO for your plugin: complete the list - _controller_units: Union[str, List[str]] = 'mm' # TODO for your plugin: put the correct unit here, it could be - # TODO a single str (the same one is applied to all axes) or a list of str (as much as the number of axes) - _epsilon: Union[float, List[float]] = 0.1 # TODO replace this by a value that is correct depending on your controller - # TODO it could be a single float of a list of float (as much as the number of axes) - data_actuator_type = DataActuatorType.DataActuator # wether you use the new data style for actuator otherwise set this - # as DataActuatorType.float (or entirely remove the line) - - params = [ # TODO for your custom plugin: elements to be added here as dicts in order to control your custom stage - ] + comon_parameters_fun(is_multiaxes, axis_names=_axis_names, epsilon=_epsilon) - # _epsilon is the initial default value for the epsilon parameter allowing pymodaq to know if the controller reached - # the target value. It is the developer responsibility to put here a meaningful value - - def ini_attributes(self): - # TODO declare the type of the wrapper (and assign it to self.controller) you're going to use for easy - # autocompletion - self.controller: PythonWrapperOfYourInstrument = None - - #TODO declare here attributes you want/need to init with a default value - pass - - def get_actuator_value(self): - """Get the current value from the hardware with scaling conversion. - - Returns - ------- - float: The position obtained after scaling conversion. - """ - ## TODO for your custom plugin - raise NotImplementedError # when writing your own plugin remove this line - pos = DataActuator(data=self.controller.your_method_to_get_the_actuator_value(), # when writing your own plugin replace this line - units=self.axis_unit) - pos = self.get_position_with_scaling(pos) - return pos - - def user_condition_to_reach_target(self) -> bool: - """ Implement a condition for exiting the polling mechanism and specifying that the - target value has been reached - - Returns - ------- - bool: if True, PyMoDAQ considers the target value has been reached - """ - # TODO either delete this method if the usual polling is fine with you, but if need you can - # add here some other condition to be fullfilled either a completely new one or - # using or/and operations between the epsilon_bool and some other custom booleans - # for a usage example see DAQ_Move_brushlessMotor from the Thorlabs plugin - return True - - def close(self): - """Terminate the communication protocol""" - ## TODO for your custom plugin - raise NotImplementedError # when writing your own plugin remove this line - if self.is_master: - # self.controller.your_method_to_terminate_the_communication() # when writing your own plugin replace this line - ... - - def commit_settings(self, param: Parameter): - """Apply the consequences of a change of value in the detector settings - - Parameters - ---------- - param: Parameter - A given parameter (within detector_settings) whose value has been changed by the user - """ - ## TODO for your custom plugin - if param.name() == 'axis': - self.axis_unit = self.controller.your_method_to_get_correct_axis_unit() - # do this only if you can and if the units are not known beforehand, for instance - # if the motors connected to the controller are of different type (mm, µm, nm, , etc...) - # see BrushlessDCMotor from the thorlabs plugin for an exemple - - elif param.name() == "a_parameter_you've_added_in_self.params": - self.controller.your_method_to_apply_this_param_change() - else: - pass - - def ini_stage(self, controller=None): - """Actuator communication initialization - - Parameters - ---------- - controller: (object) - custom object of a PyMoDAQ plugin (Slave case). None if only one actuator by controller (Master case) - - Returns - ------- - info: str - initialized: bool - False if initialization failed otherwise True - """ - raise NotImplementedError # TODO when writing your own plugin remove this line and modify the ones below - if self.is_master: # is needed when controller is master - self.controller = PythonWrapperOfYourInstrument(arg1, arg2, ...) # arguments for instantiation!) - initialized = self.controller.a_method_or_atttribute_to_check_if_init() # todo - # todo: enter here whatever is needed for your controller initialization and eventual - # opening of the communication channel - else: - self.controller = controller - initialized = True - - info = "Whatever info you want to log" - return info, initialized - - def move_abs(self, value: DataActuator): - """ Move the actuator to the absolute target defined by value - - Parameters - ---------- - value: (float) value of the absolute target positioning - """ - - value = self.check_bound(value) #if user checked bounds, the defined bounds are applied here - self.target_value = value - value = self.set_position_with_scaling(value) # apply scaling if the user specified one - ## TODO for your custom plugin - raise NotImplementedError # when writing your own plugin remove this line - self.controller.your_method_to_set_an_absolute_value(value.value(self.axis_unit)) # when writing your own plugin replace this line - self.emit_status(ThreadCommand('Update_Status', ['Some info you want to log'])) - - def move_rel(self, value: DataActuator): - """ Move the actuator to the relative target actuator value defined by value - - Parameters - ---------- - value: (float) value of the relative target positioning - """ - value = self.check_bound(self.current_position + value) - self.current_position - self.target_value = value + self.current_position - value = self.set_position_relative_with_scaling(value) - - ## TODO for your custom plugin - raise NotImplementedError # when writing your own plugin remove this line - self.controller.your_method_to_set_a_relative_value(value.value(self.axis_unit)) # when writing your own plugin replace this line - self.emit_status(ThreadCommand('Update_Status', ['Some info you want to log'])) - - def move_home(self): - """Call the reference method of the controller""" - - ## TODO for your custom plugin - raise NotImplementedError # when writing your own plugin remove this line - self.controller.your_method_to_get_to_a_known_reference() # when writing your own plugin replace this line - self.emit_status(ThreadCommand('Update_Status', ['Some info you want to log'])) - - def stop_motion(self): - """Stop the actuator and emits move_done signal""" - - ## TODO for your custom plugin - raise NotImplementedError # when writing your own plugin remove this line - self.controller.your_method_to_stop_positioning() # when writing your own plugin replace this line - self.emit_status(ThreadCommand('Update_Status', ['Some info you want to log'])) - - -if __name__ == '__main__': - main(__file__) diff --git a/src/pymodaq_plugins_princeton_instruments/daq_viewer_plugins/plugins_0D/daq_0Dviewer_Template.py b/src/pymodaq_plugins_princeton_instruments/daq_viewer_plugins/plugins_0D/daq_0Dviewer_Template.py deleted file mode 100644 index f75a38c..0000000 --- a/src/pymodaq_plugins_princeton_instruments/daq_viewer_plugins/plugins_0D/daq_0Dviewer_Template.py +++ /dev/null @@ -1,157 +0,0 @@ -import numpy as np - -from pymodaq_utils.utils import ThreadCommand -from pymodaq_data.data import DataToExport -from pymodaq_gui.parameter import Parameter - -from pymodaq.control_modules.viewer_utility_classes import DAQ_Viewer_base, comon_parameters, main -from pymodaq.utils.data import DataFromPlugins - -class PythonWrapperOfYourInstrument: - # TODO Replace this fake class with the import of the real python wrapper of your instrument - pass - -# TODO: -# (1) change the name of the following class to DAQ_0DViewer_TheNameOfYourChoice -# (2) change the name of this file to daq_0Dviewer_TheNameOfYourChoice ("TheNameOfYourChoice" should be the SAME -# for the class name and the file name.) -# (3) this file should then be put into the right folder, namely IN THE FOLDER OF THE PLUGIN YOU ARE DEVELOPING: -# pymodaq_plugins_my_plugin/daq_viewer_plugins/plugins_0D - -class DAQ_0DViewer_Template(DAQ_Viewer_base): - """ Instrument plugin class for a OD viewer. - - This object inherits all functionalities to communicate with PyMoDAQ’s DAQ_Viewer module through inheritance via - DAQ_Viewer_base. It makes a bridge between the DAQ_Viewer module and the Python wrapper of a particular instrument. - - TODO Complete the docstring of your plugin with: - * The set of instruments that should be compatible with this instrument plugin. - * With which instrument it has actually been tested. - * The version of PyMoDAQ during the test. - * The version of the operating system. - * Installation instructions: what manufacturer’s drivers should be installed to make it run? - - Attributes: - ----------- - controller: object - The particular object that allow the communication with the hardware, in general a python wrapper around the - hardware library. - - # TODO add your particular attributes here if any - - """ - params = comon_parameters+[ - ## TODO for your custom plugin: elements to be added here as dicts in order to control your custom stage - ] - - def ini_attributes(self): - # TODO declare the type of the wrapper (and assign it to self.controller) you're going to use for easy - # autocompletion - self.controller: PythonWrapperOfYourInstrument = None - - #TODO declare here attributes you want/need to init with a default value - pass - - def commit_settings(self, param: Parameter): - """Apply the consequences of a change of value in the detector settings - - Parameters - ---------- - param: Parameter - A given parameter (within detector_settings) whose value has been changed by the user - """ - ## TODO for your custom plugin - if param.name() == "a_parameter_you've_added_in_self.params": - self.controller.your_method_to_apply_this_param_change() # when writing your own plugin replace this line -# elif ... - ## - - def ini_detector(self, controller=None): - """Detector communication initialization - - Parameters - ---------- - controller: (object) - custom object of a PyMoDAQ plugin (Slave case). None if only one actuator/detector by controller - (Master case) - - Returns - ------- - info: str - initialized: bool - False if initialization failed otherwise True - """ - - raise NotImplementedError # TODO when writing your own plugin remove this line and modify the one below - if self.is_master: - self.controller = PythonWrapperOfYourInstrument() #instantiate you driver with whatever arguments are needed - self.controller.open_communication() # call eventual methods - initialized = self.controller.a_method_or_atttribute_to_check_if_init() # TODO - else: - self.controller = controller - initialized = True - - # TODO for your custom plugin (optional) initialize viewers panel with the future type of data - self.dte_signal_temp.emit(DataToExport(name='myplugin', - data=[DataFromPlugins(name='Mock1', - data=[np.array([0]), np.array([0])], - dim='Data0D', - labels=['Mock1', 'label2'])])) - - info = "Whatever info you want to log" - return info, initialized - - def close(self): - """Terminate the communication protocol""" - ## TODO for your custom plugin - raise NotImplementedError # when writing your own plugin remove this line - if self.is_master: - # self.controller.your_method_to_terminate_the_communication() # when writing your own plugin replace this line - ... - - def grab_data(self, Naverage=1, **kwargs): - """Start a grab from the detector - - Parameters - ---------- - Naverage: int - Number of hardware averaging (if hardware averaging is possible, self.hardware_averaging should be set to - True in class preamble and you should code this implementation) - kwargs: dict - others optionals arguments - """ - ## TODO for your custom plugin: you should choose EITHER the synchrone or the asynchrone version following - - # synchrone version (blocking function) - raise NotImplementedError # when writing your own plugin remove this line - data_tot = self.controller.your_method_to_start_a_grab_snap() - self.dte_signal.emit(DataToExport(name='myplugin', - data=[DataFromPlugins(name='Mock1', data=data_tot, - dim='Data0D', labels=['dat0', 'data1'])])) - ######################################################### - - # asynchrone version (non-blocking function with callback) - raise NotImplementedError # when writing your own plugin remove this line - self.controller.your_method_to_start_a_grab_snap(self.callback) # when writing your own plugin replace this line - ######################################################### - - - def callback(self): - """optional asynchrone method called when the detector has finished its acquisition of data""" - data_tot = self.controller.your_method_to_get_data_from_buffer() - self.dte_signal.emit(DataToExport(name='myplugin', - data=[DataFromPlugins(name='Mock1', data=data_tot, - dim='Data0D', labels=['dat0', 'data1'])])) - - def stop(self): - """Stop the current grab hardware wise if necessary""" - ## TODO for your custom plugin - raise NotImplementedError # when writing your own plugin remove this line - self.controller.your_method_to_stop_acquisition() # when writing your own plugin replace this line - self.emit_status(ThreadCommand('Update_Status', ['Some info you want to log'])) - ############################## - return '' - - -if __name__ == '__main__': - main(__file__) diff --git a/src/pymodaq_plugins_princeton_instruments/daq_viewer_plugins/plugins_1D/daq_1Dviewer_Template.py b/src/pymodaq_plugins_princeton_instruments/daq_viewer_plugins/plugins_1D/daq_1Dviewer_Template.py deleted file mode 100644 index 34b7a6c..0000000 --- a/src/pymodaq_plugins_princeton_instruments/daq_viewer_plugins/plugins_1D/daq_1Dviewer_Template.py +++ /dev/null @@ -1,166 +0,0 @@ -import numpy as np - -from pymodaq_utils.utils import ThreadCommand -from pymodaq_data.data import DataToExport, Axis -from pymodaq_gui.parameter import Parameter - -from pymodaq.control_modules.viewer_utility_classes import DAQ_Viewer_base, comon_parameters, main -from pymodaq.utils.data import DataFromPlugins - - -class PythonWrapperOfYourInstrument: - # TODO Replace this fake class with the import of the real python wrapper of your instrument - pass - -# TODO: -# (1) change the name of the following class to DAQ_1DViewer_TheNameOfYourChoice -# (2) change the name of this file to daq_1Dviewer_TheNameOfYourChoice ("TheNameOfYourChoice" should be the SAME -# for the class name and the file name.) -# (3) this file should then be put into the right folder, namely IN THE FOLDER OF THE PLUGIN YOU ARE DEVELOPING: -# pymodaq_plugins_my_plugin/daq_viewer_plugins/plugins_1D - - -class DAQ_1DViewer_Template(DAQ_Viewer_base): - """ Instrument plugin class for a 1D viewer. - - This object inherits all functionalities to communicate with PyMoDAQ’s DAQ_Viewer module through inheritance via - DAQ_Viewer_base. It makes a bridge between the DAQ_Viewer module and the Python wrapper of a particular instrument. - - TODO Complete the docstring of your plugin with: - * The set of instruments that should be compatible with this instrument plugin. - * With which instrument it has actually been tested. - * The version of PyMoDAQ during the test. - * The version of the operating system. - * Installation instructions: what manufacturer’s drivers should be installed to make it run? - - Attributes: - ----------- - controller: object - The particular object that allow the communication with the hardware, in general a python wrapper around the - hardware library. - - # TODO add your particular attributes here if any - - """ - params = comon_parameters+[ - ## TODO for your custom plugin - # elements to be added here as dicts in order to control your custom stage - ############ - ] - - def ini_attributes(self): - # TODO declare the type of the wrapper (and assign it to self.controller) you're going to use for easy - # autocompletion - self.controller: PythonWrapperOfYourInstrument = None - - # TODO declare here attributes you want/need to init with a default value - - self.x_axis = None - - def commit_settings(self, param: Parameter): - """Apply the consequences of a change of value in the detector settings - - Parameters - ---------- - param: Parameter - A given parameter (within detector_settings) whose value has been changed by the user - """ - ## TODO for your custom plugin - if param.name() == "a_parameter_you've_added_in_self.params": - self.controller.your_method_to_apply_this_param_change() -# elif ... - ## - - def ini_detector(self, controller=None): - """Detector communication initialization - - Parameters - ---------- - controller: (object) - custom object of a PyMoDAQ plugin (Slave case). None if only one actuator/detector by controller - (Master case) - - Returns - ------- - info: str - initialized: bool - False if initialization failed otherwise True - """ - - raise NotImplementedError # TODO when writing your own plugin remove this line and modify the one below - if self.is_master: - self.controller = PythonWrapperOfYourInstrument() #instantiate you driver with whatever arguments are needed - self.controller.open_communication() # call eventual methods - initialized = self.controller.a_method_or_atttribute_to_check_if_init() # TODO - else: - self.controller = controller - initialized = True - - ## TODO for your custom plugin - # get the x_axis (you may want to to this also in the commit settings if x_axis may have changed - data_x_axis = self.controller.your_method_to_get_the_x_axis() # if possible - self.x_axis = Axis(data=data_x_axis, label='', units='', index=0) - - # TODO for your custom plugin. Initialize viewers pannel with the future type of data - self.dte_signal_temp.emit(DataToExport(name='myplugin', - data=[DataFromPlugins(name='Mock1', - data=[np.array([0., 0., ...]), - np.array([0., 0., ...])], - dim='Data1D', labels=['Mock1', 'label2'], - axes=[self.x_axis])])) - - info = "Whatever info you want to log" - return info, initialized - - def close(self): - """Terminate the communication protocol""" - ## TODO for your custom plugin - raise NotImplementedError # when writing your own plugin remove this line - if self.is_master: - # self.controller.your_method_to_terminate_the_communication() # when writing your own plugin replace this line - ... - - def grab_data(self, Naverage=1, **kwargs): - """Start a grab from the detector - - Parameters - ---------- - Naverage: int - Number of hardware averaging (if hardware averaging is possible, self.hardware_averaging should be set to - True in class preamble and you should code this implementation) - kwargs: dict - others optionals arguments - """ - ## TODO for your custom plugin: you should choose EITHER the synchrone or the asynchrone version following - - ##synchrone version (blocking function) - data_tot = self.controller.your_method_to_start_a_grab_snap() - self.dte_signal.emit(DataToExport('myplugin', - data=[DataFromPlugins(name='Mock1', data=data_tot, - dim='Data1D', labels=['dat0', 'data1'], - axes=[self.x_axis])])) - - ##asynchrone version (non-blocking function with callback) - self.controller.your_method_to_start_a_grab_snap(self.callback) - ######################################################### - - - def callback(self): - """optional asynchrone method called when the detector has finished its acquisition of data""" - data_tot = self.controller.your_method_to_get_data_from_buffer() - self.dte_signal.emit(DataToExport('myplugin', - data=[DataFromPlugins(name='Mock1', data=data_tot, - dim='Data1D', labels=['dat0', 'data1'])])) - - def stop(self): - """Stop the current grab hardware wise if necessary""" - ## TODO for your custom plugin - raise NotImplementedError # when writing your own plugin remove this line - self.controller.your_method_to_stop_acquisition() # when writing your own plugin replace this line - self.emit_status(ThreadCommand('Update_Status', ['Some info you want to log'])) - ############################## - return '' - - -if __name__ == '__main__': - main(__file__) diff --git a/tests/test_plugin_package_structure.py b/tests/test_plugin_package_structure.py index fb9548a..a7bdf2b 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.2). - 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 is not installed + available = False +if not available: + pytest.skip('The plugin acceptance checks need PyMoDAQ >= 5.3.2', 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_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