pymodaq_utils 5.3.4__tar.gz → 5.3.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. {pymodaq_utils-5.3.4 → pymodaq_utils-5.3.6}/PKG-INFO +1 -1
  2. pymodaq_utils-5.3.6/src/pymodaq_utils/plugin_checks.py +14 -0
  3. pymodaq_utils-5.3.6/src/pymodaq_utils/plugin_rules.py +14 -0
  4. pymodaq_utils-5.3.6/src/pymodaq_utils/plugin_scaffold.py +14 -0
  5. pymodaq_utils-5.3.6/src/pymodaq_utils/plugin_tools/__init__.py +12 -0
  6. pymodaq_utils-5.3.4/src/pymodaq_utils/plugin_checks.py → pymodaq_utils-5.3.6/src/pymodaq_utils/plugin_tools/checks.py +38 -13
  7. pymodaq_utils-5.3.6/src/pymodaq_utils/plugin_tools/contract.py +115 -0
  8. pymodaq_utils-5.3.6/src/pymodaq_utils/plugin_tools/platforms.py +229 -0
  9. pymodaq_utils-5.3.4/src/pymodaq_utils/plugin_rules.py → pymodaq_utils-5.3.6/src/pymodaq_utils/plugin_tools/rules.py +105 -14
  10. pymodaq_utils-5.3.4/src/pymodaq_utils/plugin_scaffold.py → pymodaq_utils-5.3.6/src/pymodaq_utils/plugin_tools/scaffold.py +3 -3
  11. {pymodaq_utils-5.3.4 → pymodaq_utils-5.3.6}/src/pymodaq_utils/resources/hatch_build_plugins.py +6 -0
  12. {pymodaq_utils-5.3.4 → pymodaq_utils-5.3.6}/.gitignore +0 -0
  13. {pymodaq_utils-5.3.4 → pymodaq_utils-5.3.6}/LICENSE +0 -0
  14. {pymodaq_utils-5.3.4 → pymodaq_utils-5.3.6}/README.rst +0 -0
  15. {pymodaq_utils-5.3.4 → pymodaq_utils-5.3.6}/pyproject.toml +0 -0
  16. {pymodaq_utils-5.3.4 → pymodaq_utils-5.3.6}/src/pymodaq_utils/__init__.py +0 -0
  17. {pymodaq_utils-5.3.4 → pymodaq_utils-5.3.6}/src/pymodaq_utils/abstract.py +0 -0
  18. {pymodaq_utils-5.3.4 → pymodaq_utils-5.3.6}/src/pymodaq_utils/array_manipulation.py +0 -0
  19. {pymodaq_utils-5.3.4 → pymodaq_utils-5.3.6}/src/pymodaq_utils/categorizing.py +0 -0
  20. {pymodaq_utils-5.3.4 → pymodaq_utils-5.3.6}/src/pymodaq_utils/config.py +0 -0
  21. {pymodaq_utils-5.3.4 → pymodaq_utils-5.3.6}/src/pymodaq_utils/enums.py +0 -0
  22. {pymodaq_utils-5.3.4 → pymodaq_utils-5.3.6}/src/pymodaq_utils/environment.py +0 -0
  23. {pymodaq_utils-5.3.4 → pymodaq_utils-5.3.6}/src/pymodaq_utils/factory.py +0 -0
  24. {pymodaq_utils-5.3.4 → pymodaq_utils-5.3.6}/src/pymodaq_utils/help.py +0 -0
  25. {pymodaq_utils-5.3.4 → pymodaq_utils-5.3.6}/src/pymodaq_utils/icon.ico +0 -0
  26. {pymodaq_utils-5.3.4 → pymodaq_utils-5.3.6}/src/pymodaq_utils/logger.py +0 -0
  27. {pymodaq_utils-5.3.4 → pymodaq_utils-5.3.6}/src/pymodaq_utils/math_utils.py +0 -0
  28. {pymodaq_utils-5.3.4 → pymodaq_utils-5.3.6}/src/pymodaq_utils/mysocket.py +0 -0
  29. {pymodaq_utils-5.3.4 → pymodaq_utils-5.3.6}/src/pymodaq_utils/packages.py +0 -0
  30. {pymodaq_utils-5.3.4 → pymodaq_utils-5.3.6}/src/pymodaq_utils/plugin_testing.py +0 -0
  31. {pymodaq_utils-5.3.4 → pymodaq_utils-5.3.6}/src/pymodaq_utils/resources/__init__.py +0 -0
  32. {pymodaq_utils-5.3.4 → pymodaq_utils-5.3.6}/src/pymodaq_utils/resources/config_template.toml +0 -0
  33. {pymodaq_utils-5.3.4 → pymodaq_utils-5.3.6}/src/pymodaq_utils/serialize/__init__.py +0 -0
  34. {pymodaq_utils-5.3.4 → pymodaq_utils-5.3.6}/src/pymodaq_utils/serialize/factory.py +0 -0
  35. {pymodaq_utils-5.3.4 → pymodaq_utils-5.3.6}/src/pymodaq_utils/serialize/mysocket.py +0 -0
  36. {pymodaq_utils-5.3.4 → pymodaq_utils-5.3.6}/src/pymodaq_utils/serialize/serializer.py +0 -0
  37. {pymodaq_utils-5.3.4 → pymodaq_utils-5.3.6}/src/pymodaq_utils/serialize/serializer_legacy.py +0 -0
  38. {pymodaq_utils-5.3.4 → pymodaq_utils-5.3.6}/src/pymodaq_utils/serialize/utils.py +0 -0
  39. {pymodaq_utils-5.3.4 → pymodaq_utils-5.3.6}/src/pymodaq_utils/singleton.py +0 -0
  40. {pymodaq_utils-5.3.4 → pymodaq_utils-5.3.6}/src/pymodaq_utils/splash.png +0 -0
  41. {pymodaq_utils-5.3.4 → pymodaq_utils-5.3.6}/src/pymodaq_utils/units.py +0 -0
  42. {pymodaq_utils-5.3.4 → pymodaq_utils-5.3.6}/src/pymodaq_utils/utils.py +0 -0
  43. {pymodaq_utils-5.3.4 → pymodaq_utils-5.3.6}/src/pymodaq_utils/warnings.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pymodaq_utils
3
- Version: 5.3.4
3
+ Version: 5.3.6
4
4
  Summary: Modular Data Acquisition with Python
5
5
  Project-URL: Homepage, http://pymodaq.cnrs.fr
6
6
  Project-URL: Source, https://github.com/PyMoDAQ/PyMoDAQ
@@ -0,0 +1,14 @@
1
+ """Moved to :mod:`pymodaq_utils.plugin_tools.checks`, this module is an alias kept for existing code.
2
+
3
+ .. versionadded:: 5.3.1
4
+ .. deprecated:: 5.3.5
5
+ Use ``pymodaq_utils.plugin_tools.checks``.
6
+ """
7
+ import sys
8
+
9
+ from pymodaq_utils.plugin_tools import checks as _module
10
+
11
+ if __name__ == '__main__': # python -m pymodaq_utils.plugin_checks
12
+ raise SystemExit(_module.main())
13
+
14
+ sys.modules[__name__] = _module
@@ -0,0 +1,14 @@
1
+ """Moved to :mod:`pymodaq_utils.plugin_tools.rules`, this module is an alias kept for existing code.
2
+
3
+ .. versionadded:: 5.3.1
4
+ .. deprecated:: 5.3.5
5
+ Use ``pymodaq_utils.plugin_tools.rules``.
6
+ """
7
+ import sys
8
+
9
+ from pymodaq_utils.plugin_tools import rules as _module
10
+
11
+ if __name__ == '__main__': # python -m pymodaq_utils.plugin_rules
12
+ raise SystemExit(_module.main())
13
+
14
+ sys.modules[__name__] = _module
@@ -0,0 +1,14 @@
1
+ """Moved to :mod:`pymodaq_utils.plugin_tools.scaffold`, this module is an alias kept for existing code.
2
+
3
+ .. versionadded:: 5.3.1
4
+ .. deprecated:: 5.3.5
5
+ Use ``pymodaq_utils.plugin_tools.scaffold``.
6
+ """
7
+ import sys
8
+
9
+ from pymodaq_utils.plugin_tools import scaffold as _module
10
+
11
+ if __name__ == '__main__': # python -m pymodaq_utils.plugin_scaffold
12
+ raise SystemExit(_module.main())
13
+
14
+ sys.modules[__name__] = _module
@@ -0,0 +1,12 @@
1
+ """Tools to develop and maintain PyMoDAQ instrument plugins.
2
+
3
+ * :mod:`~pymodaq_utils.plugin_tools.contract`: the methods of a plugin (single definition)
4
+ * :mod:`~pymodaq_utils.plugin_tools.rules`: the static rules (``PMQxxx`` codes)
5
+ * :mod:`~pymodaq_utils.plugin_tools.checks`: the report on a plugin package (``check_plugin`` command)
6
+ * :mod:`~pymodaq_utils.plugin_tools.scaffold`: creation of new plugin modules
7
+
8
+ They read the source files of the plugins and do not import ``pymodaq``. The previous locations
9
+ (``pymodaq_utils.plugin_tools.checks``, ``plugin_rules`` and ``plugin_scaffold``) still work.
10
+
11
+ .. versionadded:: 5.3.5
12
+ """
@@ -12,7 +12,7 @@ Every check is also exposed as a plain function returning a list of problems (em
12
12
 
13
13
  To get a report without pytest, for any installed plugin package::
14
14
 
15
- from pymodaq_utils.plugin_checks import check_plugin_package
15
+ from pymodaq_utils.plugin_tools.checks import check_plugin_package
16
16
  print(check_plugin_package('pymodaq_plugins_mock'))
17
17
 
18
18
  .. versionadded:: 5.3.1
@@ -33,19 +33,21 @@ import toml
33
33
 
34
34
  from pymodaq_utils.utils import get_entrypoints
35
35
 
36
- from pymodaq_utils.plugin_rules import (PLACEHOLDER_NAMES_RE, Finding, Severity, check_file_names, check_leftovers,
37
- check_plugin_source, check_python_code, check_pyproject, colorize, is_valid_unit, package_root,
38
- project_root, use_color)
36
+ from pymodaq_utils.plugin_tools.contract import legacy_names
37
+ from pymodaq_utils.plugin_tools import platforms
38
+ from pymodaq_utils.plugin_tools.rules import (MANDATORY_MOVE_METHODS, MANDATORY_VIEWER_METHODS, PLACEHOLDER_NAMES_RE,
39
+ Finding, Severity, check_file_names, check_leftovers,
40
+ check_os_classifiers, check_plugin_source, check_python_code,
41
+ check_pyproject, colorize, is_valid_unit, package_root, project_root,
42
+ use_color)
39
43
 
40
44
  VIEWER_DIMS = ('0D', '1D', '2D', 'ND')
41
45
  ENTRYPOINT_GROUPS = ('pymodaq.plugins', 'pymodaq.instruments')
42
46
  OPTIONAL_ENTRYPOINT_GROUPS = ('pymodaq.extensions', 'pymodaq.models', 'pymodaq.pid_models',
43
47
  'pymodaq.scanners', 'pymodaq.h5exporters')
44
48
 
45
- # methods that have to be overridden by the plugin (they are abstract in the base classes, but these are not
46
- # enforced as the bases are QObjects)
47
- MANDATORY_MOVE_METHODS = ('ini_stage', 'get_actuator_value', 'close') # stop_motion has a default that does nothing
48
- MANDATORY_VIEWER_METHODS = ('ini_detector', 'grab_data', 'stop', 'close')
49
+ # the methods that have to be overridden by the plugin are defined in pymodaq_utils.plugin_tools.contract (they are abstract
50
+ # in the base classes, but this is not enforced as the bases are QObjects)
49
51
 
50
52
  # files generated by parser generators (pycparser/ply) that can end up within the plugin packages
51
53
  IGNORED_MODULES = ('lextab', 'yacctab')
@@ -206,8 +208,10 @@ def _check_params(klass: type) -> list[str]:
206
208
  return problems
207
209
 
208
210
 
209
- def _check_methods(klass: type, base: type, mandatory) -> list[str]:
210
- problems = [f'{klass.__name__} should override {m}()' for m in mandatory if not _is_overridden(klass, base, m)]
211
+ def _check_methods(klass: type, base: type, mandatory, legacy: Optional[dict] = None) -> list[str]:
212
+ legacy = legacy or {} # old names still called by the base class if the method is not defined (see PMQ312)
213
+ problems = [f'{klass.__name__} should override {m}()' for m in mandatory
214
+ if not _is_overridden(klass, base, m) and not (m in legacy and hasattr(klass, legacy[m]))]
211
215
  if inspect.isabstract(klass): # in case a base ever becomes a real ABC
212
216
  problems.append(f'{klass.__name__} is abstract: {sorted(klass.__abstractmethods__)}')
213
217
  return problems
@@ -218,7 +222,7 @@ def check_move_class(klass: type) -> list[str]:
218
222
  from pymodaq.control_modules.move_utility_classes import DAQ_Move_base # only needed when checking the classes
219
223
  if not (inspect.isclass(klass) and issubclass(klass, DAQ_Move_base)):
220
224
  return [f'{klass} should derive from DAQ_Move_base']
221
- problems = _check_methods(klass, DAQ_Move_base, MANDATORY_MOVE_METHODS)
225
+ problems = _check_methods(klass, DAQ_Move_base, MANDATORY_MOVE_METHODS, legacy_names('move'))
222
226
  problems += _check_params(klass)
223
227
 
224
228
  axis_names = klass.get_class_axis_names()
@@ -331,7 +335,7 @@ class CheckResult:
331
335
 
332
336
  ``problems`` are defects of the plugin found on the imported classes. ``warnings`` mean the item could not be
333
337
  fully checked, for instance because a third party module or a vendor SDK is not available in the current
334
- environment. ``findings`` come from the static rules of :mod:`pymodaq_utils.plugin_rules`.
338
+ environment. ``findings`` come from the static rules of :mod:`pymodaq_utils.plugin_tools.rules`.
335
339
  """
336
340
  item: str
337
341
  problems: list[str] = field(default_factory=list)
@@ -462,6 +466,7 @@ def check_package_sources(package: str, project: Optional[Path] = None) -> Check
462
466
  project = project or project_root(package) # None if installed from a wheel: no pyproject.toml to check
463
467
  if project is not None:
464
468
  result.findings.extend(check_pyproject(package, project))
469
+ result.findings.extend(check_os_classifiers(root, project))
465
470
  result.findings.extend(check_file_names(root))
466
471
  result.findings.extend(check_leftovers(package, root, project))
467
472
  result.findings.extend(check_python_code(root))
@@ -607,7 +612,7 @@ def check_plugin_file(path: str | Path, fail_on: str = 'error', strict_imports:
607
612
 
608
613
  def main(argv=None) -> int:
609
614
  """Print the report of a plugin package, for the developer: the ``check_plugin`` command (or
610
- ``python -m pymodaq_utils.plugin_checks``)
615
+ ``python -m pymodaq_utils.plugin_tools.checks``)
611
616
 
612
617
  Returns 0 if the checks pass, 1 otherwise (so that it can also be used in a script or a CI).
613
618
  """
@@ -623,6 +628,9 @@ def main(argv=None) -> int:
623
628
  parser.add_argument('--strict-imports', action='store_true',
624
629
  help='fail on the modules that cannot be imported (missing dependency or SDK)')
625
630
  parser.add_argument('-v', '--verbose', action='store_true', help='list every todo')
631
+ parser.add_argument('--fix-classifiers', action='store_true',
632
+ help='write the Operating System classifiers of the pyproject.toml from the '
633
+ 'supported_platforms declared by the instrument plugins')
626
634
  parser.add_argument('--color', choices=['auto', 'always', 'never'], default='auto',
627
635
  help='color the report: auto (default) if the output is a terminal and NO_COLOR is not set')
628
636
  args = parser.parse_args(argv)
@@ -637,9 +645,26 @@ def main(argv=None) -> int:
637
645
  package, project = resolve_target(args.package)
638
646
  except ValueError as e:
639
647
  parser.error(str(e))
648
+ if args.fix_classifiers:
649
+ root = package_root(package)
650
+ if root is None or project is None:
651
+ parser.error('--fix-classifiers needs the folder of the plugin repository (with its pyproject.toml)')
652
+ if 'classifiers' in toml.load(project / 'pyproject.toml').get('project', {}).get('dynamic', []):
653
+ print('pyproject.toml: the classifiers are computed when the package is built, nothing to write')
654
+ else:
655
+ try:
656
+ changed = platforms.update_pyproject_classifiers(project / 'pyproject.toml',
657
+ platforms.package_platforms(root))
658
+ except ValueError as e:
659
+ parser.error(str(e))
660
+ print('pyproject.toml: classifiers ' + ('updated' if changed else 'already up to date'))
640
661
  report = check_plugin_package(package, args.fail_on, args.strict_imports, project,
641
662
  check_entry_points=is_installed(package) or project is None)
642
663
  print(report.format(verbose=args.verbose, color=use_color(args.color)))
664
+ root = package_root(package)
665
+ declared = platforms.package_platforms(root) if root is not None else None
666
+ if declared:
667
+ print(f'Works on: {platforms.describe(declared)}')
643
668
  return 0 if report.ok else 1
644
669
 
645
670
 
@@ -0,0 +1,115 @@
1
+ """The contract of an instrument plugin: the methods a plugin class has to (or should) override.
2
+
3
+ This is the single place where these lists are defined. The static rules (:mod:`pymodaq_utils.plugin_tools.rules`), the
4
+ checks (:mod:`pymodaq_utils.plugin_tools.checks`), the plugin tests and the documentation read them from here, so they
5
+ cannot disagree.
6
+
7
+ .. versionadded:: 5.3.5
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from typing import Literal
13
+
14
+ Kind = Literal['move', 'viewer']
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class PluginMethod:
19
+ """One method of an actuator or detector plugin
20
+
21
+ Parameters
22
+ ----------
23
+ name: str
24
+ name of the method in the plugin class
25
+ kind: 'move' or 'viewer'
26
+ actuator (``DAQ_Move_base``) or detector (``DAQ_Viewer_base``)
27
+ mandatory: bool
28
+ if True, a plugin that does not override it is wrong (error). Otherwise the base class has a default
29
+ that does nothing or raises ``NotImplementedError``: override it when the instrument can do it.
30
+ purpose: str
31
+ when it is called and what it has to do
32
+ returns: str
33
+ what it has to return ('' if nothing)
34
+ legacy_name: str
35
+ deprecated name that PyMoDAQ still calls if the method is not defined ('' if none): a plugin defining it
36
+ fulfils the contract (with a deprecation warning, see rule PMQ312)
37
+ """
38
+ name: str
39
+ kind: Kind
40
+ mandatory: bool
41
+ purpose: str
42
+ returns: str = ''
43
+ legacy_name: str = ''
44
+
45
+
46
+ PLUGIN_METHODS: tuple[PluginMethod, ...] = (
47
+ PluginMethod('ini_attributes', 'move', False,
48
+ 'called first, in the constructor: declare the attributes (type hints, ``self.controller = None``)'),
49
+ PluginMethod('ini_stage', 'move', True,
50
+ 'initialize the communication when the actuator is initialized; if ``self.is_master`` create the '
51
+ 'controller, otherwise use the ``controller`` argument (shared with the master)',
52
+ '``(info: str, initialized: bool)``'),
53
+ PluginMethod('get_actuator_value', 'move', True,
54
+ 'read the current position from the hardware, on request and after each move',
55
+ 'a ``DataActuator`` with units (or a float if ``data_actuator_type`` is ``float``)',
56
+ legacy_name='check_position'),
57
+ PluginMethod('move_abs', 'move', True,
58
+ 'move to an absolute value (a ``DataActuator``, in the units of the axis)',
59
+ legacy_name='move_Abs'),
60
+ PluginMethod('move_rel', 'move', True,
61
+ 'move by a relative value (a ``DataActuator``, in the units of the axis)',
62
+ legacy_name='move_Rel'),
63
+ PluginMethod('move_home', 'move', False, 'send the actuator to its home position'),
64
+ PluginMethod('stop_motion', 'move', False, 'stop the motion now; the default does nothing'),
65
+ PluginMethod('commit_settings', 'move', False,
66
+ 'a setting of the tree changed in the UI: send it to the hardware'),
67
+ PluginMethod('close', 'move', True, 'release the hardware (safe state, close the communication)'),
68
+ PluginMethod('ini_attributes', 'viewer', False,
69
+ 'called first, in the constructor: declare the attributes (type hints, ``self.controller = None``)'),
70
+ PluginMethod('ini_detector', 'viewer', True,
71
+ 'initialize the communication when the detector is initialized; if ``self.is_master`` create the '
72
+ 'controller, otherwise use the ``controller`` argument (shared with the master)',
73
+ '``(info: str, initialized: bool)``'),
74
+ PluginMethod('grab_data', 'viewer', True,
75
+ 'start an acquisition (``Naverage`` averages); send the data with ``self.dte_signal.emit`` '
76
+ '(or ``dte_signal_temp``) as a ``DataToExport`` with axes and units'),
77
+ PluginMethod('stop', 'viewer', True, 'stop the acquisition in progress'),
78
+ PluginMethod('commit_settings', 'viewer', False,
79
+ 'a setting of the tree changed in the UI: send it to the hardware'),
80
+ PluginMethod('close', 'viewer', True, 'release the hardware (safe state, close the communication)'),
81
+ )
82
+
83
+
84
+ def methods_of(kind: Kind) -> tuple[PluginMethod, ...]:
85
+ """All the methods of the contract of an actuator ('move') or of a detector ('viewer')"""
86
+ return tuple(m for m in PLUGIN_METHODS if m.kind == kind)
87
+
88
+
89
+ def mandatory_methods(kind: Kind) -> tuple[str, ...]:
90
+ """Names of the methods that a plugin has to override"""
91
+ return tuple(m.name for m in methods_of(kind) if m.mandatory)
92
+
93
+
94
+ def legacy_names(kind: Kind) -> dict[str, str]:
95
+ """Deprecated names still accepted in place of the mandatory methods: {method name: old name}"""
96
+ return {m.name: m.legacy_name for m in methods_of(kind) if m.mandatory and m.legacy_name}
97
+
98
+
99
+ def recommended_methods(kind: Kind) -> tuple[str, ...]:
100
+ """Names of the other methods of the contract: to override when the instrument can do it"""
101
+ return tuple(m.name for m in methods_of(kind) if not m.mandatory)
102
+
103
+
104
+ def contract_as_rst() -> str:
105
+ """The contract as two reStructuredText tables, to be included in the documentation"""
106
+ out = []
107
+ for kind, title in (('move', 'Actuator (``DAQ_Move_base``)'), ('viewer', 'Detector (``DAQ_Viewer_base``)')):
108
+ out += [title, '-' * len(title), '',
109
+ '.. list-table::', ' :header-rows: 1', ' :widths: 20 12 48 20', '',
110
+ ' * - Method', ' - Required', ' - When it is called, what it does', ' - Returns']
111
+ for m in methods_of(kind):
112
+ out += [f' * - ``{m.name}``', f' - {"yes" if m.mandatory else "no"}',
113
+ f' - {m.purpose}', f' - {m.returns}']
114
+ out.append('')
115
+ return '\n'.join(out)
@@ -0,0 +1,229 @@
1
+ """Operating systems and processor architectures supported by an instrument plugin.
2
+
3
+ A plugin that only works on some systems (a Windows driver, a Raspberry Pi board...) declares it in the class of its
4
+ instrument plugin::
5
+
6
+ class DAQ_Move_Xxxx(DAQ_Move_base):
7
+ supported_platforms = ('win32',) # values of sys.platform: 'win32', 'linux', 'darwin'
8
+ supported_architectures = ('aarch64', 'armv7l') # optional, values of platform.machine()
9
+
10
+ ``None`` (the default of the base classes) means all of them. PyMoDAQ then refuses to initialize the plugin on another
11
+ system, with a message, and ``check_plugin`` reads the declaration to check (and update with ``--fix-classifiers``)
12
+ the ``Operating System`` classifiers of the ``pyproject.toml``.
13
+
14
+ .. versionadded:: 5.3.5
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import ast
19
+ import platform
20
+ import re
21
+ import sys
22
+ from pathlib import Path
23
+ from typing import Iterable, Optional
24
+
25
+ #: values of ``sys.platform`` and the matching name and PyPI classifier
26
+ PLATFORMS = {
27
+ 'win32': ('Windows', 'Operating System :: Microsoft :: Windows'),
28
+ 'linux': ('Linux', 'Operating System :: POSIX :: Linux'),
29
+ 'darwin': ('macOS', 'Operating System :: MacOS'),
30
+ }
31
+ OS_INDEPENDENT = 'Operating System :: OS Independent'
32
+
33
+ #: values of ``platform.machine()`` (compared without case), with their aliases
34
+ ARCHITECTURES = {
35
+ 'x86_64': ('x86_64', 'amd64'),
36
+ 'i686': ('i686', 'i386', 'x86'),
37
+ 'aarch64': ('aarch64', 'arm64'),
38
+ 'armv7l': ('armv7l', 'armv8l'),
39
+ 'armv6l': ('armv6l',),
40
+ }
41
+ _ARCH_ALIASES = {alias: name for name, aliases in ARCHITECTURES.items() for alias in aliases}
42
+
43
+ PLUGIN_CLASS_RE = re.compile(r'^DAQ_(Move|[0-2N]DViewer)_\w+$')
44
+ _OS_CLASSIFIER_RE = re.compile(r'^(?P<indent>\s*)(?P<quote>["\'])Operating System ::.*$')
45
+
46
+
47
+ def _as_tuple(value) -> Optional[tuple]:
48
+ if value is None:
49
+ return None
50
+ return (value,) if isinstance(value, str) else tuple(value)
51
+
52
+
53
+ def normalize_architecture(machine: str) -> str:
54
+ """Canonical name of an architecture (``arm64`` -> ``aarch64``...), the lowercased input if unknown"""
55
+ return _ARCH_ALIASES.get(machine.lower(), machine.lower())
56
+
57
+
58
+ def describe(platforms: Optional[Iterable[str]] = None, architectures: Optional[Iterable[str]] = None) -> str:
59
+ """Text for the user, for instance ``Linux (aarch64, armv7l)``; empty if there is no restriction"""
60
+ platforms, architectures = _as_tuple(platforms), _as_tuple(architectures)
61
+ text = ' or '.join(PLATFORMS.get(p, (p,))[0] for p in platforms) if platforms else ''
62
+ if architectures:
63
+ arch = ', '.join(architectures)
64
+ text = f'{text} ({arch})' if text else f'the architectures {arch}'
65
+ return text
66
+
67
+
68
+ def unsupported_reason(platforms: Optional[Iterable[str]] = None, architectures: Optional[Iterable[str]] = None,
69
+ system: Optional[str] = None, machine: Optional[str] = None) -> str:
70
+ """Why a plugin cannot run on this computer, or an empty string if it can
71
+
72
+ Parameters
73
+ ----------
74
+ platforms: iterable of str or None
75
+ values of ``sys.platform`` the plugin works on (``None``: all). A prefix is enough: ``linux`` matches
76
+ ``linux2``
77
+ architectures: iterable of str or None
78
+ values of ``platform.machine()`` the plugin works on (``None``: all)
79
+ system, machine: str
80
+ replace ``sys.platform`` and ``platform.machine()``, for tests
81
+ """
82
+ platforms, architectures = _as_tuple(platforms), _as_tuple(architectures)
83
+ system = sys.platform if system is None else system
84
+ machine = platform.machine() if machine is None else machine
85
+ ok_system = not platforms or any(system.startswith(p) for p in platforms)
86
+ ok_machine = not architectures or normalize_architecture(machine) in {normalize_architecture(a)
87
+ for a in architectures}
88
+ if ok_system and ok_machine:
89
+ return ''
90
+ return f'This plugin only works on {describe(platforms, architectures)} (this computer: {system}, {machine})'
91
+
92
+
93
+ def classifiers_for(platforms: Optional[Iterable[str]] = None) -> list[str]:
94
+ """The ``Operating System`` classifiers of a plugin working on these systems (``None``: OS independent)"""
95
+ platforms = _as_tuple(platforms)
96
+ if not platforms:
97
+ return [OS_INDEPENDENT]
98
+ return [PLATFORMS[p][1] for p in PLATFORMS if p in platforms]
99
+
100
+
101
+ def invalid_values(platforms: Optional[Iterable[str]], architectures: Optional[Iterable[str]]) -> list[str]:
102
+ """Problems of a declaration: unknown system or architecture names, empty lists"""
103
+ problems = []
104
+ platforms, architectures = _as_tuple(platforms), _as_tuple(architectures)
105
+ if platforms is not None:
106
+ if not platforms:
107
+ problems.append('supported_platforms is empty, use None for all the systems')
108
+ problems.extend(f"unknown system '{p}' in supported_platforms, use {list(PLATFORMS)}"
109
+ for p in platforms if p not in PLATFORMS)
110
+ if architectures is not None:
111
+ if not architectures:
112
+ problems.append('supported_architectures is empty, use None for all the architectures')
113
+ problems.extend(f"unknown architecture '{a}' in supported_architectures, use values of platform.machine() "
114
+ f"like {list(ARCHITECTURES)}" for a in architectures if normalize_architecture(a) not in
115
+ ARCHITECTURES)
116
+ return problems
117
+
118
+
119
+ def declared_in_class(cls: ast.ClassDef) -> tuple[Optional[tuple], Optional[tuple]]:
120
+ """``(supported_platforms, supported_architectures)`` written in the body of a plugin class (None if absent or
121
+ not a literal)"""
122
+ found = {}
123
+ for node in cls.body:
124
+ targets = []
125
+ if isinstance(node, ast.Assign):
126
+ targets, value = [t for t in node.targets if isinstance(t, ast.Name)], node.value
127
+ elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name) and node.value is not None:
128
+ targets, value = [node.target], node.value
129
+ for target in targets:
130
+ if target.id in ('supported_platforms', 'supported_architectures'):
131
+ try:
132
+ found[target.id] = _as_tuple(ast.literal_eval(value))
133
+ except (ValueError, SyntaxError):
134
+ pass
135
+ return found.get('supported_platforms'), found.get('supported_architectures')
136
+
137
+
138
+ def package_platforms(root: Path) -> Optional[set]:
139
+ """Systems on which the instruments of a plugin package work (union over the instrument modules), read from the
140
+ sources. ``None`` if at least one instrument works on all of them"""
141
+ systems: set = set()
142
+ files = sorted(set(root.rglob('daq_move_*.py')) | set(root.rglob('daq_*viewer_*.py')))
143
+ for file in files:
144
+ try:
145
+ tree = ast.parse(file.read_text(encoding='utf-8'))
146
+ except (SyntaxError, UnicodeDecodeError, OSError):
147
+ continue
148
+ for cls in (n for n in tree.body if isinstance(n, ast.ClassDef) and PLUGIN_CLASS_RE.match(n.name)):
149
+ declared, _ = declared_in_class(cls)
150
+ if not declared:
151
+ return None
152
+ systems.update(declared)
153
+ return systems or None
154
+
155
+
156
+ def pyproject_os_classifiers(classifiers: Iterable[str]) -> list[str]:
157
+ return [c for c in classifiers if c.startswith('Operating System ::')]
158
+
159
+
160
+ def update_pyproject_classifiers(pyproject: Path, platforms: Optional[Iterable[str]]) -> bool:
161
+ """Replace the ``Operating System`` classifiers in the ``classifiers`` list of a ``pyproject.toml`` (a text edit,
162
+ the rest of the file is kept). Returns True if the file changed"""
163
+ wanted = classifiers_for(platforms)
164
+ lines = pyproject.read_text(encoding='utf-8').splitlines(keepends=True)
165
+ start = next((i for i, line in enumerate(lines) if re.match(r'^\s*classifiers\s*=\s*\[', line)), None)
166
+ if start is None:
167
+ raise ValueError('no classifiers = [...] list in the pyproject.toml (dynamic classifiers?)')
168
+ end = next((i for i in range(start, len(lines)) if lines[i].strip().startswith(']') or
169
+ (i == start and lines[i].rstrip().endswith(']'))), None)
170
+ if end is None or end == start:
171
+ raise ValueError('the classifiers list must be written on several lines')
172
+ indexes = [i for i in range(start + 1, end) if _OS_CLASSIFIER_RE.match(lines[i])]
173
+ sample = lines[indexes[0]] if indexes else lines[end - 1]
174
+ indent = re.match(r'\s*', sample).group(0) or ' '
175
+ quote = '"'
176
+ new = [f'{indent}{quote}{c}{quote},\n' for c in wanted]
177
+ if indexes:
178
+ first = indexes[0]
179
+ rebuilt = [line for i, line in enumerate(lines) if i not in indexes]
180
+ rebuilt[first:first] = new
181
+ else:
182
+ rebuilt = lines[:end] + new + lines[end:]
183
+ if lines[end - 1].rstrip().endswith('"') or lines[end - 1].rstrip().endswith("'"):
184
+ rebuilt[end - 1] = lines[end - 1].rstrip() + ',\n'
185
+ changed = rebuilt != lines
186
+ if changed:
187
+ pyproject.write_text(''.join(rebuilt), encoding='utf-8')
188
+ return changed
189
+
190
+
191
+ #: Python versions PyMoDAQ is developed for, they give the ``Programming Language :: Python`` classifiers
192
+ PYTHON_VERSIONS = ('3.10', '3.11', '3.12', '3.13')
193
+
194
+
195
+ def python_classifiers(requires_python: str = '') -> list[str]:
196
+ """``Programming Language :: Python :: 3.x`` for each of :data:`PYTHON_VERSIONS` accepted by ``requires-python``
197
+ (all of them if empty or invalid)"""
198
+ from packaging.specifiers import InvalidSpecifier, SpecifierSet
199
+ try:
200
+ spec = SpecifierSet(requires_python or '')
201
+ except InvalidSpecifier:
202
+ spec = SpecifierSet('')
203
+ return [f'Programming Language :: Python :: {v}' for v in PYTHON_VERSIONS if spec.contains(f'{v}.0')]
204
+
205
+
206
+ def dynamic_classifiers(pyproject: dict, package_folder: Path) -> list[str]:
207
+ """The classifiers of a plugin whose ``classifiers`` field is *dynamic* in the ``pyproject.toml``: those listed in
208
+ ``[tool.pymodaq] classifiers`` (the ``Operating System`` and ``Programming Language :: Python :: 3.x`` ones are
209
+ ignored) followed by the Python versions accepted by ``requires-python`` and the systems computed from the
210
+ ``supported_platforms`` of its instrument plugins. Used by the hatch metadata hook at build time"""
211
+ listed = pyproject.get('tool', {}).get('pymodaq', {}).get('classifiers', [])
212
+ kept = [c for c in listed if not c.startswith(('Operating System ::', 'Programming Language :: Python :: 3.'))]
213
+ return (kept + python_classifiers(pyproject.get('project', {}).get('requires-python', '')) +
214
+ classifiers_for(package_platforms(package_folder)))
215
+
216
+
217
+ def platforms_as_rst() -> str:
218
+ """The accepted values as two reStructuredText tables, to be included in the documentation"""
219
+ out = ['.. list-table::', ' :header-rows: 1', ' :widths: 20 20 60', '',
220
+ ' * - ``supported_platforms``', ' - System', ' - Classifier']
221
+ for key, (label, classifier) in PLATFORMS.items():
222
+ out += [f' * - ``{key}``', f' - {label}', f' - ``{classifier}``']
223
+ out += ['', '.. list-table::', ' :header-rows: 1', ' :widths: 30 70', '',
224
+ ' * - ``supported_architectures``', ' - Also accepted (same architecture)']
225
+ for key, aliases in ARCHITECTURES.items():
226
+ other = ', '.join(f'``{a}``' for a in aliases if a != key)
227
+ out += [f' * - ``{key}``', f' - {other}']
228
+ out.append('')
229
+ return '\n'.join(out)
@@ -10,9 +10,9 @@ Severities:
10
10
  * ``warning``: deprecated or suspicious, should be fixed
11
11
  * ``todo``: the plugin is not finished (leftover of the template: TODO comments, placeholders, ``NotImplementedError``)
12
12
 
13
- They are used by :mod:`pymodaq_utils.plugin_checks` but can be called directly::
13
+ They are used by :mod:`pymodaq_utils.plugin_tools.checks` but can be called directly::
14
14
 
15
- from pymodaq_utils.plugin_rules import check_package_sources
15
+ from pymodaq_utils.plugin_tools.rules import check_package_sources
16
16
  for finding in check_package_sources('pymodaq_plugins_xxxx'):
17
17
  print(finding)
18
18
 
@@ -21,6 +21,7 @@ They are used by :mod:`pymodaq_utils.plugin_checks` but can be called directly::
21
21
  from __future__ import annotations
22
22
 
23
23
  import ast
24
+ import functools
24
25
  import importlib.util
25
26
  import os
26
27
  import re
@@ -33,6 +34,9 @@ from typing import Optional
33
34
 
34
35
  import toml
35
36
 
37
+ from pymodaq_utils.plugin_tools.contract import legacy_names, mandatory_methods
38
+ from pymodaq_utils.plugin_tools import platforms as _platforms
39
+
36
40
  DOC = 'https://pymodaq.cnrs.fr/en/latest/developer_folder/instrument_plugins.html'
37
41
  TEMPLATE_NAME = 'pymodaq_plugins_template'
38
42
 
@@ -43,8 +47,9 @@ PLACEHOLDER_AUTHORS = ('Name Surname', 'myname@test.fr')
43
47
  PLACEHOLDER_NAMES_RE = re.compile(r'python_wrapper_file_of_your_instrument|PythonWrapperObjectOfYourInstrument|'
44
48
  r'your_method_to_\w+|a_method_or_atttribute_to_check_if_init')
45
49
 
46
- MANDATORY_MOVE_METHODS = ('ini_stage', 'get_actuator_value', 'close') # stop_motion has a default that does nothing
47
- MANDATORY_VIEWER_METHODS = ('ini_detector', 'grab_data', 'stop', 'close')
50
+ # defined once in pymodaq_utils.plugin_tools.contract
51
+ MANDATORY_MOVE_METHODS = mandatory_methods('move')
52
+ MANDATORY_VIEWER_METHODS = mandatory_methods('viewer')
48
53
 
49
54
  # hooks of the plugins that were renamed: PyMoDAQ still calls the old ones, with a deprecation warning
50
55
  DEPRECATED_HOOKS = {'check_position': 'get_actuator_value', 'move_Abs': 'move_abs', 'move_Rel': 'move_rel',
@@ -131,9 +136,17 @@ class Finding:
131
136
  def location(self) -> str:
132
137
  if self.path is None:
133
138
  return ''
139
+ name = self._path_in_package()
134
140
  if not self.line:
135
- return self.path.name
136
- return f'{self.path.name}:{self.line}' + (f':{self.column}' if self.column else '')
141
+ return name
142
+ return f'{name}:{self.line}' + (f':{self.column}' if self.column else '')
143
+
144
+ def _path_in_package(self) -> str:
145
+ """Path of the file from the folder of the plugin package (``hardware/driver.py``), its name if outside"""
146
+ for parent in self.path.parents:
147
+ if parent.name.startswith('pymodaq_plugins_'):
148
+ return self.path.relative_to(parent).as_posix()
149
+ return self.path.name
137
150
 
138
151
  def format(self, color: bool = False) -> str:
139
152
  """Text of the finding, with colors for a terminal if ``color``"""
@@ -224,9 +237,11 @@ def check_python_code(root: Path) -> list[Finding]:
224
237
  * PMQ315: invalid escape sequence in a string (``"C:\\Program Files"``): a warning of python since 3.6, a
225
238
  ``SyntaxWarning`` since 3.12, that will be an error
226
239
  * PMQ317: code that only works on Windows and is executed when the module is imported (``windll``, ``winreg``,
227
- path with a drive letter): the plugin cannot be imported, nor listed, on the other systems
240
+ path with a drive letter): the plugin cannot be imported, nor listed, on the other systems. Not reported when
241
+ all the instruments of the package declare ``supported_platforms = ('win32',)``
228
242
  """
229
243
  findings = []
244
+ windows_only_package = _platforms.package_platforms(root) == {'win32'}
230
245
  for path in _python_files(root):
231
246
  source = path.read_text(errors='replace')
232
247
  with warnings.catch_warnings(record=True) as caught:
@@ -255,7 +270,7 @@ def check_python_code(root: Path) -> list[Finding]:
255
270
  what = f'the path {node.value!r}'
256
271
  if what is not None and not _guarded(node, parents):
257
272
  windows.append((node.lineno, what))
258
- if windows:
273
+ if windows and not windows_only_package:
259
274
  line, what = windows[0]
260
275
  more = f' (and {len(windows) - 1} more in this file)' if len(windows) > 1 else ''
261
276
  findings.append(Finding('PMQ317', Severity.WARNING, f'only works on Windows: {what}{more}',
@@ -400,6 +415,31 @@ def check_leftovers(package: str, root: Path, project: Optional[Path] = None) ->
400
415
  return findings
401
416
 
402
417
 
418
+ NOQA_RE = re.compile(r'#\s*noqa\s*:?\s*(PMQ\d+(?:\s*,\s*PMQ\d+)*)', re.IGNORECASE)
419
+
420
+
421
+ def _honour_noqa(rule):
422
+ """Drop the findings of a rule whose line carries a ``# noqa: PMQxxx`` comment (on purpose, like for flake8)"""
423
+ @functools.wraps(rule)
424
+ def wrapper(*args, **kwargs):
425
+ lines = {}
426
+ kept = []
427
+ for finding in rule(*args, **kwargs):
428
+ if finding.path is not None and finding.line is not None:
429
+ if finding.path not in lines:
430
+ try:
431
+ lines[finding.path] = Path(finding.path).read_text(errors='replace').splitlines()
432
+ except OSError:
433
+ lines[finding.path] = []
434
+ text = lines[finding.path]
435
+ match = NOQA_RE.search(text[finding.line - 1]) if 0 < finding.line <= len(text) else None
436
+ if match and finding.code in {c.strip().upper() for c in match.group(1).split(',')}:
437
+ continue
438
+ kept.append(finding)
439
+ return kept
440
+ return wrapper
441
+
442
+
403
443
  def _is_name(node: Optional[ast.AST], name: str) -> bool:
404
444
  if isinstance(node, ast.Call):
405
445
  node = node.func
@@ -482,11 +522,19 @@ def check_plugin_source(path: Path, kind: str, class_name: str, static_fallback:
482
522
  else:
483
523
  mandatory = MANDATORY_VIEWER_METHODS
484
524
  findings.extend(_check_legacy_settings(path, tree))
525
+ findings.extend(_check_controller_init(path, tree))
526
+ for problem in _platforms.invalid_values(*_platforms.declared_in_class(cls)):
527
+ findings.append(Finding('PMQ319', Severity.WARNING, problem,
528
+ 'supported_platforms: values of sys.platform (win32, linux, darwin); '
529
+ 'supported_architectures: values of platform.machine() (x86_64, aarch64, armv7l...)',
530
+ path, cls.lineno))
485
531
  simple_bases = all(b in ('DAQ_Move_base', 'DAQ_Viewer_base') for b in _base_names(cls))
486
532
  if static_fallback and simple_bases:
487
533
  for meth in mandatory:
488
- # the base class still calls check_position if get_actuator_value is not defined (see PMQ312)
489
- if meth not in methods and not (meth == 'get_actuator_value' and 'check_position' in methods):
534
+ # the base class still calls the old names (check_position, move_Abs...) if the method is not defined
535
+ # (see PMQ312)
536
+ old = legacy_names(kind).get(meth) if kind == 'move' else None
537
+ if meth not in methods and not (old and old in methods):
490
538
  findings.append(Finding('PMQ306', Severity.ERROR, f'{class_name} should define {meth}()',
491
539
  'implement it (see the template)', path, cls.lineno))
492
540
  init = methods.get('ini_stage' if kind == 'move' else 'ini_detector')
@@ -512,7 +560,8 @@ def _check_move_source(path: Path, cls: ast.ClassDef, attrs: dict, static_fallba
512
560
  findings.append(Finding('PMQ302', Severity.ERROR, "the axes are declared as 'axis_names', that is ignored",
513
561
  "use '_axis_names'", path, attrs['axis_names'].lineno))
514
562
  dat = attrs.get('data_actuator_type')
515
- if dat is None or 'DataActuator' not in ast.dump(dat):
563
+ inherits = dat is None and not all(b == 'DAQ_Move_base' for b in _base_names(cls)) # may be set by a parent
564
+ if not inherits and (dat is None or 'DataActuator' not in ast.dump(dat)):
516
565
  findings.append(Finding('PMQ303', Severity.WARNING, 'data_actuator_type is not DataActuatorType.DataActuator',
517
566
  'new plugins should exchange DataActuator objects: set '
518
567
  'data_actuator_type = DataActuatorType.DataActuator', path,
@@ -572,14 +621,28 @@ def _check_legacy_settings(path: Path, tree: ast.Module) -> list[Finding]:
572
621
  more = f' ({len(hits)} times in this file)' if len(hits) > 1 else ''
573
622
  return [Finding('PMQ316', Severity.WARNING, f"settings group '{first.value}' was renamed "
574
623
  f"'{LEGACY_SETTINGS[first.value]}'{more}",
575
- "PyMoDAQ only translates the old names in settings[...], not in settings.child(...): use the "
576
- "new ones", path,
624
+ "PyMoDAQ still translates the old names in settings[...] (deprecated), but not in "
625
+ "settings.child(...): use the new ones", path,
577
626
  first.lineno)]
578
627
 
579
628
 
629
+ def _check_controller_init(path: Path, tree: ast.Module) -> list[Finding]:
630
+ """PMQ318: deprecated call of ini_stage_init / ini_detector_init to initialize the controller"""
631
+ calls = [n for n in ast.walk(tree) if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute) and
632
+ n.func.attr in ('ini_stage_init', 'ini_detector_init') and
633
+ isinstance(n.func.value, ast.Name) and n.func.value.id == 'self']
634
+ if not calls:
635
+ return []
636
+ first = min(calls, key=lambda n: n.lineno)
637
+ more = f' ({len(calls)} times in this file)' if len(calls) > 1 else ''
638
+ return [Finding('PMQ318', Severity.WARNING, f'{first.func.attr}() is deprecated{more}',
639
+ "write: if self.is_master: self.controller = AWrapper() else: self.controller = controller",
640
+ path, first.lineno)]
641
+
642
+
580
643
  def check_package_sources(package: str) -> list[Finding]:
581
644
  """All the static rules on an installed (or editable) plugin package: packaging, leftovers and plugin classes"""
582
- from pymodaq_utils.plugin_checks import find_plugin_modules # no import cycle at module level
645
+ from pymodaq_utils.plugin_tools.checks import find_plugin_modules # no import cycle at module level
583
646
 
584
647
  root = package_root(package)
585
648
  if root is None:
@@ -593,3 +656,31 @@ def check_package_sources(package: str) -> list[Finding]:
593
656
  file = Path(importlib.util.find_spec(mod.import_path).origin)
594
657
  findings.extend(check_plugin_source(file, mod.kind, mod.class_name))
595
658
  return findings
659
+
660
+
661
+ def check_os_classifiers(package_folder: Path, project: Path) -> list[Finding]:
662
+ """PMQ320: the ``Operating System`` classifiers of the pyproject.toml do not match the ``supported_platforms``
663
+ declared by the instrument plugins"""
664
+ path = project / 'pyproject.toml'
665
+ try:
666
+ proj = toml.load(path).get('project', {})
667
+ except toml.TomlDecodeError:
668
+ return []
669
+ if 'classifiers' in proj.get('dynamic', []):
670
+ return []
671
+ declared = _platforms.package_platforms(package_folder)
672
+ expected = set(_platforms.classifiers_for(declared))
673
+ actual = set(_platforms.pyproject_os_classifiers(proj.get('classifiers', [])))
674
+ if actual == expected:
675
+ return []
676
+ what = (f"the plugins work on {_platforms.describe(declared)}" if declared else
677
+ 'no instrument plugin declares supported_platforms (they work on all the systems)')
678
+ line = next((i for i, text in enumerate(path.read_text().splitlines(), 1) if 'classifiers' in text), None)
679
+ return [Finding('PMQ320', Severity.WARNING,
680
+ f'the Operating System classifiers {sorted(actual) or "(none)"} do not match the code: {what}',
681
+ 'run check_plugin --fix-classifiers to write ' + ', '.join(sorted(expected)), path, line)]
682
+
683
+
684
+ check_plugin_source = _honour_noqa(check_plugin_source)
685
+ check_python_code = _honour_noqa(check_python_code)
686
+ check_leftovers = _honour_noqa(check_leftovers)
@@ -1,11 +1,11 @@
1
1
  """Helper to add a new, correctly named, instrument to a PyMoDAQ plugin package (``pymodaq_plugins_*``).
2
2
 
3
- It is a development helper, independent of the plugin checks (see :mod:`pymodaq_utils.plugin_checks`). The generated
3
+ It is a development helper, independent of the plugin checks (see :mod:`pymodaq_utils.plugin_tools.checks`). The generated
4
4
  module follows the naming convention, derives from the right base class, declares the mandatory attributes and
5
5
  methods, and holds ``TODO`` comments (and ``NotImplementedError``) where the instrument specific code goes::
6
6
 
7
- python -m pymodaq_utils.plugin_scaffold move Xxxx # actuator: daq_move_Xxxx.py, class DAQ_Move_Xxxx
8
- python -m pymodaq_utils.plugin_scaffold 1D Xxxx # 1D detector: daq_1Dviewer_Xxxx.py, class DAQ_1DViewer_Xxxx
7
+ python -m pymodaq_utils.plugin_tools.scaffold move Xxxx # actuator: daq_move_Xxxx.py, class DAQ_Move_Xxxx
8
+ python -m pymodaq_utils.plugin_tools.scaffold 1D Xxxx # 1D detector: daq_1Dviewer_Xxxx.py, class DAQ_1DViewer_Xxxx
9
9
 
10
10
  from the root of the plugin repository (or use ``--folder`` to give the package folder). The same is available from
11
11
  python with :func:`create_instrument`.
@@ -1,6 +1,7 @@
1
1
  from pathlib import Path
2
2
  import toml
3
3
 
4
+ from pymodaq_utils.plugin_tools.platforms import dynamic_classifiers
4
5
  from pymodaq_utils.logger import set_logger, get_module_name
5
6
 
6
7
  logger = set_logger(get_module_name(__file__))
@@ -43,3 +44,8 @@ def update_metadata_from_toml(metadata: dict, here: Path) -> None:
43
44
  metadata['entry-points'] = entrypoints
44
45
 
45
46
  logger.debug(f'created entry-points: {entrypoints}')
47
+
48
+ if 'classifiers' in src_dict.get('project', {}).get('dynamic', []):
49
+ folder = here / 'src' / PLUGIN_NAME
50
+ metadata['classifiers'] = dynamic_classifiers(src_dict, folder if folder.is_dir() else here / PLUGIN_NAME)
51
+ logger.debug(f"created classifiers: {metadata['classifiers']}")
File without changes
File without changes
File without changes