pymodaq_utils 5.3.6__tar.gz → 5.3.7__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 (44) hide show
  1. {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/PKG-INFO +1 -1
  2. {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/array_manipulation.py +1 -1
  3. {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/math_utils.py +3 -1
  4. {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/plugin_tools/checks.py +2 -1
  5. pymodaq_utils-5.3.7/src/pymodaq_utils/plugin_tools/extension_checks.py +102 -0
  6. {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/plugin_tools/platforms.py +72 -0
  7. {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/plugin_tools/rules.py +163 -4
  8. {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/plugin_tools/scaffold.py +35 -5
  9. {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/.gitignore +0 -0
  10. {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/LICENSE +0 -0
  11. {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/README.rst +0 -0
  12. {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/pyproject.toml +0 -0
  13. {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/__init__.py +0 -0
  14. {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/abstract.py +0 -0
  15. {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/categorizing.py +0 -0
  16. {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/config.py +0 -0
  17. {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/enums.py +0 -0
  18. {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/environment.py +0 -0
  19. {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/factory.py +0 -0
  20. {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/help.py +0 -0
  21. {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/icon.ico +0 -0
  22. {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/logger.py +0 -0
  23. {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/mysocket.py +0 -0
  24. {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/packages.py +0 -0
  25. {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/plugin_checks.py +0 -0
  26. {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/plugin_rules.py +0 -0
  27. {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/plugin_scaffold.py +0 -0
  28. {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/plugin_testing.py +0 -0
  29. {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/plugin_tools/__init__.py +0 -0
  30. {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/plugin_tools/contract.py +0 -0
  31. {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/resources/__init__.py +0 -0
  32. {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/resources/config_template.toml +0 -0
  33. {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/resources/hatch_build_plugins.py +0 -0
  34. {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/serialize/__init__.py +0 -0
  35. {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/serialize/factory.py +0 -0
  36. {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/serialize/mysocket.py +0 -0
  37. {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/serialize/serializer.py +0 -0
  38. {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/serialize/serializer_legacy.py +0 -0
  39. {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/serialize/utils.py +0 -0
  40. {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/singleton.py +0 -0
  41. {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/splash.png +0 -0
  42. {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/units.py +0 -0
  43. {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/utils.py +0 -0
  44. {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/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.6
3
+ Version: 5.3.7
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
@@ -379,7 +379,7 @@ def min_ind(x, axis=None):
379
379
 
380
380
 
381
381
  if __name__ == '__main__': # pragma: no cover
382
- from pymodaq.utils import daq_utils as utils
382
+ from pymodaq_utils import math_utils as utils
383
383
  import matplotlib.pyplot as plt
384
384
 
385
385
  x = random_step(00, 100, 5)
@@ -14,6 +14,7 @@ logger = set_logger(get_module_name(__file__))
14
14
 
15
15
 
16
16
  def get_si_prefix(number) -> tuple[Number, str]:
17
+ """ Get the Scaled SI prefix of a Number """
17
18
  if number == 0:
18
19
  return 0, ""
19
20
 
@@ -33,7 +34,8 @@ def get_si_prefix(number) -> tuple[Number, str]:
33
34
  # Calculate the scaled number component
34
35
  scaled_value = number / (10 ** exponent)
35
36
  prefix_symbol = prefixes[exponent]
36
-
37
+ if prefix_symbol == '':
38
+ scaled_value = number
37
39
  return scaled_value, prefix_symbol
38
40
 
39
41
 
@@ -36,7 +36,7 @@ from pymodaq_utils.utils import get_entrypoints
36
36
  from pymodaq_utils.plugin_tools.contract import legacy_names
37
37
  from pymodaq_utils.plugin_tools import platforms
38
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,
39
+ Finding, Severity, check_extensions, check_file_names, check_leftovers,
40
40
  check_os_classifiers, check_plugin_source, check_python_code,
41
41
  check_pyproject, colorize, is_valid_unit, package_root, project_root,
42
42
  use_color)
@@ -470,6 +470,7 @@ def check_package_sources(package: str, project: Optional[Path] = None) -> Check
470
470
  result.findings.extend(check_file_names(root))
471
471
  result.findings.extend(check_leftovers(package, root, project))
472
472
  result.findings.extend(check_python_code(root))
473
+ result.findings.extend(check_extensions(root))
473
474
  return result
474
475
 
475
476
 
@@ -0,0 +1,102 @@
1
+ """Check the modules of Dashboard extensions: the ``check_extension`` command.
2
+
3
+ It applies the rules PMQ330 to PMQ339 (see :func:`pymodaq_utils.plugin_tools.rules.check_extension_source`) to a python
4
+ file or to a folder, whether the extension belongs to a plugin package or to PyMoDAQ itself. The sources are read,
5
+ nothing is imported. ``check_plugin`` also applies these rules to the ``extensions`` folder of a plugin package.
6
+
7
+ .. versionadded:: 5.3.7
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import sys
12
+ from pathlib import Path
13
+ from typing import Optional, Sequence
14
+
15
+ from pymodaq_utils.plugin_tools.checks import FAIL_LEVELS, CheckResult, PluginReport
16
+ from pymodaq_utils.plugin_tools.rules import check_extension_source, use_color
17
+
18
+
19
+ def is_core_extension(path: Path) -> bool:
20
+ """Whether a file is in the ``extensions`` of the ``pymodaq`` package: the core extensions are registered in
21
+ ``pymodaq.extensions`` and do not need to define ``EXTENSION_NAME`` and ``CLASS_NAME``"""
22
+ parts = path.resolve().parts
23
+ return any(a == 'pymodaq' and b == 'extensions' for a, b in zip(parts, parts[1:]))
24
+
25
+
26
+ def find_extension_files(target: Path) -> list[Path]:
27
+ """The python files to check: the file itself, or the modules of a folder
28
+
29
+ For a folder holding an ``extensions`` folder (the plugin package, or the repository with its ``src`` folder) the
30
+ modules of this folder are taken, otherwise all the modules of the folder and its sub-folders. The ``__init__``
31
+ and the private modules (starting with ``_``) are left out.
32
+ """
33
+ if target.is_file():
34
+ return [target]
35
+ folder = next((c for c in (target / 'extensions', *sorted(target.glob('src/*/extensions')),
36
+ *sorted(target.glob('pymodaq_plugins_*/extensions'))) if c.is_dir()), target)
37
+ return [p for p in sorted(folder.rglob('*.py'))
38
+ if not p.name.startswith('_') and '__pycache__' not in p.parts]
39
+
40
+
41
+ def check_extension_files(targets: Sequence[str | Path], fail_on: str = 'error',
42
+ core: Optional[bool] = None) -> PluginReport:
43
+ """Run the extension rules on files or folders and return a report
44
+
45
+ Parameters
46
+ ----------
47
+ targets: list of str or Path
48
+ python files of extensions, or folders (see :func:`find_extension_files`)
49
+ fail_on: str
50
+ 'error' (default), 'warning' or 'todo', see :data:`pymodaq_utils.plugin_tools.checks.FAIL_LEVELS`
51
+ core: bool
52
+ True for the extensions of PyMoDAQ: PMQ330 (``EXTENSION_NAME`` and ``CLASS_NAME`` missing) is then not
53
+ reported. By default found from the path of each file (``pymodaq/extensions``).
54
+ """
55
+ if fail_on not in FAIL_LEVELS:
56
+ raise ValueError(f'fail_on should be one of {list(FAIL_LEVELS)}')
57
+ results = []
58
+ for target in targets:
59
+ target = Path(target)
60
+ if not target.exists():
61
+ raise FileNotFoundError(target)
62
+ for path in find_extension_files(target):
63
+ is_core = is_core_extension(path) if core is None else core
64
+ findings = [f for f in check_extension_source(path) if not (is_core and f.code == 'PMQ330')]
65
+ try:
66
+ item = path.resolve().relative_to(Path.cwd()).as_posix()
67
+ except ValueError:
68
+ item = str(path)
69
+ results.append(CheckResult(item, findings=findings))
70
+ return PluginReport('extensions', results, fail_on)
71
+
72
+
73
+ def main(argv=None) -> int:
74
+ """Print the report on extension modules: the ``check_extension`` command (or
75
+ ``python -m pymodaq_utils.plugin_tools.extension_checks``)
76
+
77
+ Returns 0 if the checks pass, 1 otherwise (so that it can also be used in a script or a CI).
78
+ """
79
+ import argparse
80
+ parser = argparse.ArgumentParser(prog='check_extension',
81
+ description='Check the modules of PyMoDAQ Dashboard extensions and print a report')
82
+ parser.add_argument('paths', nargs='+',
83
+ help='python file of an extension, or folder: the extensions folder of a plugin package or of '
84
+ 'PyMoDAQ, or the plugin repository')
85
+ parser.add_argument('--fail-on', choices=list(FAIL_LEVELS), default='error',
86
+ help="findings of this level and above give a non zero exit code ('todo': everything)")
87
+ parser.add_argument('--core', action='store_true', default=None,
88
+ help='the extensions are part of PyMoDAQ: EXTENSION_NAME and CLASS_NAME are not required '
89
+ '(found from the path by default)')
90
+ parser.add_argument('--color', choices=['auto', 'always', 'never'], default='auto',
91
+ help='color the report: auto (default) if the output is a terminal and NO_COLOR is not set')
92
+ args = parser.parse_args(argv)
93
+ try:
94
+ report = check_extension_files(args.paths, args.fail_on, args.core)
95
+ except FileNotFoundError as e:
96
+ parser.error(f'{e} does not exist')
97
+ print(report.format(verbose=True, color=use_color(args.color)))
98
+ return 0 if report.ok else 1
99
+
100
+
101
+ if __name__ == '__main__':
102
+ sys.exit(main())
@@ -214,6 +214,78 @@ def dynamic_classifiers(pyproject: dict, package_folder: Path) -> list[str]:
214
214
  classifiers_for(package_platforms(package_folder)))
215
215
 
216
216
 
217
+ def _imported_files(path: Path, root: Path) -> set:
218
+ """Python files of the package imported by a module (absolute ``pymodaq_plugins_x.hardware.y`` or relative)"""
219
+ try:
220
+ tree = ast.parse(path.read_text(encoding='utf-8'))
221
+ except (SyntaxError, UnicodeDecodeError, OSError):
222
+ return set()
223
+ found = set()
224
+
225
+ def add(parts: list):
226
+ target = root.joinpath(*parts)
227
+ for candidate in (target.with_suffix('.py'), target / '__init__.py'):
228
+ if candidate.is_file():
229
+ found.add(candidate)
230
+
231
+ for node in ast.walk(tree):
232
+ if isinstance(node, ast.Import):
233
+ for alias in node.names:
234
+ parts = alias.name.split('.')
235
+ if parts[0] == root.name:
236
+ add(parts[1:])
237
+ elif isinstance(node, ast.ImportFrom):
238
+ module = (node.module or '').split('.') if node.module else []
239
+ if node.level:
240
+ base = path.parent
241
+ for _ in range(node.level - 1):
242
+ base = base.parent
243
+ try:
244
+ parts = list(base.relative_to(root).parts) + module
245
+ except ValueError:
246
+ continue
247
+ elif module[0] == root.name:
248
+ parts = module[1:]
249
+ else:
250
+ continue
251
+ add(parts)
252
+ for alias in node.names:
253
+ add(parts + [alias.name])
254
+ return found
255
+
256
+
257
+ def windows_only_files(root: Path) -> set:
258
+ """Python files of a plugin package that only the Windows-only instruments use (directly or through the other
259
+ modules): code that only works on Windows is expected there. All of them if every instrument declares
260
+ ``supported_platforms = ('win32',)``. Files used by an instrument working elsewhere are never in it"""
261
+ files = [f for f in sorted(root.rglob('*.py')) if '__pycache__' not in f.parts]
262
+ if package_platforms(root) == {'win32'}:
263
+ return set(files)
264
+ graph = {f: _imported_files(f, root) for f in files}
265
+
266
+ def reachable(start: Path) -> set:
267
+ seen, todo = {start}, [start]
268
+ while todo:
269
+ for other in graph.get(todo.pop(), ()):
270
+ if other not in seen:
271
+ seen.add(other)
272
+ todo.append(other)
273
+ return seen
274
+
275
+ windows, others = set(), set()
276
+ for f in files:
277
+ if not (f.name.startswith('daq_move_') or re.match(r'daq_\w*viewer_', f.name)):
278
+ continue
279
+ try:
280
+ tree = ast.parse(f.read_text(encoding='utf-8'))
281
+ except (SyntaxError, UnicodeDecodeError, OSError):
282
+ continue
283
+ declared = [declared_in_class(c)[0] for c in tree.body
284
+ if isinstance(c, ast.ClassDef) and PLUGIN_CLASS_RE.match(c.name)]
285
+ (windows if declared and all(d and set(d) == {'win32'} for d in declared) else others).update(reachable(f))
286
+ return windows - others
287
+
288
+
217
289
  def platforms_as_rst() -> str:
218
290
  """The accepted values as two reStructuredText tables, to be included in the documentation"""
219
291
  out = ['.. list-table::', ' :header-rows: 1', ' :widths: 20 20 60', '',
@@ -62,6 +62,20 @@ LEGACY_SETTINGS = {'multiaxes': 'controller', 'multi_status': 'controller_status
62
62
  WINDOWS_ONLY_IMPORTS = {'winreg', 'msvcrt', '_winreg', 'win32api', 'win32com'}
63
63
  WINDOWS_PATH_RE = re.compile(r'^[A-Za-z]:[\\/]')
64
64
 
65
+ # modules of pymodaq that only remain as shims importing from their new location (with a deprecation warning)
66
+ DEPRECATED_IMPORTS = {'pymodaq.extensions.custom_ext': 'pymodaq.utils.custom_ext',
67
+ 'pymodaq.extensions.h5browser': 'pymodaq_gui.h5modules.h5browser',
68
+ 'pymodaq.utils.math_utils': 'pymodaq_utils.math_utils',
69
+ 'pymodaq.utils.array_manipulation': 'pymodaq_utils.array_manipulation',
70
+ 'pymodaq.utils.parameter': 'pymodaq_gui.parameter',
71
+ 'pymodaq.utils.messenger': 'pymodaq_gui.messenger',
72
+ 'pymodaq.scripting': 'pymodaq_scripting'}
73
+ # hooks of the extensions that were renamed (the base class still calls the old ones)
74
+ DEPRECATED_EXTENSION_HOOKS = {'setup_docks': 'setup_docks_and_widgets', 'setup_menu': 'setup_menus_and_toolbars'}
75
+ # managers that an extension gets from the Dashboard: it should not create its own
76
+ # (its ModulesManager is the exception: the base class creates it for the extension)
77
+ DASHBOARD_OWNED = ('ExperimentManager', 'StateManager')
78
+
65
79
  # features of [features] in pyproject.toml -> folders (relative to the package) holding the corresponding code
66
80
  FEATURE_FOLDERS = {'instruments': ('daq_move_plugins', 'daq_viewer_plugins'),
67
81
  'extensions': ('extensions',),
@@ -237,11 +251,11 @@ def check_python_code(root: Path) -> list[Finding]:
237
251
  * PMQ315: invalid escape sequence in a string (``"C:\\Program Files"``): a warning of python since 3.6, a
238
252
  ``SyntaxWarning`` since 3.12, that will be an error
239
253
  * PMQ317: code that only works on Windows and is executed when the module is imported (``windll``, ``winreg``,
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',)``
254
+ path with a drive letter): the plugin cannot be imported, nor listed, on the other systems. Not reported in the
255
+ modules that only the instruments declaring ``supported_platforms = ('win32',)`` use
242
256
  """
243
257
  findings = []
244
- windows_only_package = _platforms.package_platforms(root) == {'win32'}
258
+ windows_only = _platforms.windows_only_files(root)
245
259
  for path in _python_files(root):
246
260
  source = path.read_text(errors='replace')
247
261
  with warnings.catch_warnings(record=True) as caught:
@@ -270,7 +284,7 @@ def check_python_code(root: Path) -> list[Finding]:
270
284
  what = f'the path {node.value!r}'
271
285
  if what is not None and not _guarded(node, parents):
272
286
  windows.append((node.lineno, what))
273
- if windows and not windows_only_package:
287
+ if windows and path not in windows_only:
274
288
  line, what = windows[0]
275
289
  more = f' (and {len(windows) - 1} more in this file)' if len(windows) > 1 else ''
276
290
  findings.append(Finding('PMQ317', Severity.WARNING, f'only works on Windows: {what}{more}',
@@ -640,6 +654,149 @@ def _check_controller_init(path: Path, tree: ast.Module) -> list[Finding]:
640
654
  path, first.lineno)]
641
655
 
642
656
 
657
+ # -------------------------------------------------------------------------------------------------------------------
658
+ # PMQ33x: extensions (``extensions`` folder of a plugin package), from the source
659
+ # -------------------------------------------------------------------------------------------------------------------
660
+
661
+ EXTENSIONS_DOC = 'https://pymodaq.cnrs.fr/en/latest/developer_folder/extension_plugins.html'
662
+
663
+
664
+ def _module_strings(tree: ast.Module) -> dict[str, ast.Assign]:
665
+ """The module level variables assigned a string, with their node"""
666
+ found = {}
667
+ for node in tree.body:
668
+ if (isinstance(node, ast.Assign) and len(node.targets) == 1 and isinstance(node.targets[0], ast.Name)
669
+ and isinstance(node.value, ast.Constant) and isinstance(node.value.value, str)):
670
+ found[node.targets[0].id] = node
671
+ return found
672
+
673
+
674
+ def _root_bases(cls: ast.ClassDef, classes: dict[str, ast.ClassDef], seen: Optional[set] = None) -> set[str]:
675
+ """Names of the bases of a class, following the bases defined in the same module"""
676
+ seen = seen or set()
677
+ names = set()
678
+ for base in _base_names(cls):
679
+ names.add(base)
680
+ if base in classes and base not in seen:
681
+ names |= _root_bases(classes[base], classes, seen | {cls.name})
682
+ return names
683
+
684
+
685
+ def check_extension_source(path: Path) -> list[Finding]:
686
+ """Rules on a module of the ``extensions`` folder of a plugin package, without importing it
687
+
688
+ * PMQ330: ``EXTENSION_NAME`` or ``CLASS_NAME`` missing in a module defining an extension: the Dashboard does not
689
+ list it
690
+ * PMQ331: ``CLASS_NAME`` is not the name of a class of the module
691
+ * PMQ332: the class does not derive from ``CustomExt`` (``CustomApp`` has no access to the Dashboard)
692
+ * PMQ333: import from a module of pymodaq that only remains as a deprecated shim
693
+ * PMQ334: deprecated hooks ``setup_docks`` / ``setup_menu``
694
+ * PMQ335: the extension creates its own ExperimentManager or StateManager
695
+ * PMQ336: the ``__init__`` does not take the ``parent`` and ``dashboard`` arguments given by the Dashboard
696
+ * PMQ337: the ``__init__`` of a class deriving from ``CustomExt`` never calls ``self.setup_ui()``
697
+ * PMQ338: a class deriving directly from ``CustomExt`` has no ``_quit_fun`` guard, or one that never returns True
698
+ * PMQ339: the extension class overrides ``quit_fun`` instead of ``_quit_fun``
699
+ """
700
+ try:
701
+ tree = ast.parse(path.read_text(errors='replace'), filename=str(path))
702
+ except SyntaxError as e:
703
+ return [syntax_error_finding('PMQ300', path, e)]
704
+ findings = []
705
+ classes = {n.name: n for n in tree.body if isinstance(n, ast.ClassDef)}
706
+ strings = _module_strings(tree)
707
+ defines_ext = any('CustomExt' in _root_bases(c, classes) for c in classes.values())
708
+
709
+ for node in ast.walk(tree):
710
+ if isinstance(node, (ast.Import, ast.ImportFrom)):
711
+ modules = [node.module or ''] if isinstance(node, ast.ImportFrom) else [a.name for a in node.names]
712
+ for module in modules:
713
+ old = next((o for o in DEPRECATED_IMPORTS if module == o or module.startswith(o + '.')), None)
714
+ if old is not None:
715
+ findings.append(Finding('PMQ333', Severity.WARNING, f"'{module}' is deprecated",
716
+ f"import from {DEPRECATED_IMPORTS[old]} instead", path, node.lineno))
717
+
718
+ if not (defines_ext or 'EXTENSION_NAME' in strings or 'CLASS_NAME' in strings):
719
+ return findings # a helper module of the extensions folder, nothing else to check
720
+
721
+ missing = [name for name in ('EXTENSION_NAME', 'CLASS_NAME') if name not in strings]
722
+ if missing:
723
+ findings.append(Finding('PMQ330', Severity.ERROR,
724
+ f"{' and '.join(missing)} not defined: the Dashboard will not list this extension",
725
+ "define them at the module level as strings: EXTENSION_NAME is the name displayed in "
726
+ "the Dashboard, CLASS_NAME the name of the extension class", path, 1))
727
+ class_name = strings['CLASS_NAME'].value.value if 'CLASS_NAME' in strings else None
728
+ imported = {a.asname or a.name.split('.')[0] for n in tree.body if isinstance(n, (ast.Import, ast.ImportFrom))
729
+ for a in n.names}
730
+ cls = classes.get(class_name) if class_name else None
731
+ if class_name and cls is None and class_name not in imported:
732
+ findings.append(Finding('PMQ331', Severity.ERROR, f"CLASS_NAME = '{class_name}' but there is no such class "
733
+ f"in the module", 'CLASS_NAME should be the name of the extension class, the Dashboard '
734
+ 'gets the class from it', path, strings['CLASS_NAME'].lineno))
735
+ if cls is not None:
736
+ bases = _root_bases(cls, classes)
737
+ if 'CustomExt' not in bases and ('CustomApp' in bases or not bases - {'object'}):
738
+ findings.append(Finding('PMQ332', Severity.WARNING, f'{cls.name} does not derive from CustomExt',
739
+ "an extension is a CustomExt (from pymodaq.utils.custom_ext) to get the "
740
+ "Dashboard, not a CustomApp", path, cls.lineno))
741
+ methods = {n.name: n for n in cls.body if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))}
742
+ for old, new in DEPRECATED_EXTENSION_HOOKS.items():
743
+ if old in methods:
744
+ findings.append(Finding('PMQ334', Severity.WARNING, f'{old}() is deprecated',
745
+ f"rename it {new}()", path, methods[old].lineno))
746
+ init = methods.get('__init__')
747
+ if init is not None:
748
+ args = init.args
749
+ if len(args.posonlyargs) + len(args.args) < 3 and args.vararg is None:
750
+ findings.append(Finding('PMQ336', Severity.ERROR, '__init__ should take (self, parent, dashboard)',
751
+ 'the Dashboard creates the extension with the dock area and itself: '
752
+ 'def __init__(self, parent, dashboard) then super().__init__(parent, dashboard)',
753
+ path, init.lineno))
754
+ if 'CustomExt' in _base_names(cls) and not any(
755
+ isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute) and n.func.attr == 'setup_ui'
756
+ for n in ast.walk(init)):
757
+ findings.append(Finding('PMQ337', Severity.WARNING, '__init__ never calls self.setup_ui()',
758
+ 'without it no dock, menu or toolbar is created: call self.setup_ui() at the '
759
+ 'end of __init__', path, init.lineno))
760
+ if 'CustomExt' in _root_bases(cls, classes) and 'quit_fun' in methods:
761
+ findings.append(Finding('PMQ339', Severity.ERROR, f'{cls.name} overrides quit_fun()',
762
+ 'quit_fun of CustomExt calls _quit_fun (the guard) then closes the window and '
763
+ 'shows the Dashboard again: override _quit_fun instead, so that these are kept',
764
+ path, methods['quit_fun'].lineno))
765
+ if 'CustomExt' in _base_names(cls) and 'quit_fun' not in methods:
766
+ quit_fun = methods.get('_quit_fun')
767
+ if quit_fun is None:
768
+ findings.append(Finding('PMQ338', Severity.WARNING, f'{cls.name} does not define _quit_fun()',
769
+ 'quit_fun only closes the extension when _quit_fun() returns True, and the '
770
+ 'one of CustomExt returns None: define _quit_fun as the guard that checks '
771
+ 'that nothing is left running (threads, workers...), return False if '
772
+ 'something is, True otherwise', path, cls.lineno))
773
+ elif not any(isinstance(n, ast.Return) and n.value is not None
774
+ and not (isinstance(n.value, ast.Constant) and not n.value.value)
775
+ for n in ast.walk(quit_fun)):
776
+ findings.append(Finding('PMQ338', Severity.WARNING, '_quit_fun() never returns True',
777
+ 'quit_fun only closes the extension when _quit_fun() returns True: it is the '
778
+ 'guard that returns False while something is left running (threads, workers'
779
+ '...) and True when everything is released', path, quit_fun.lineno))
780
+ in_main = {n for f in tree.body if isinstance(f, ast.FunctionDef) and f.name == 'main' for n in ast.walk(f)}
781
+ for node in ast.walk(tree):
782
+ if (isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id in DASHBOARD_OWNED
783
+ and node not in in_main):
784
+ findings.append(Finding('PMQ335', Severity.WARNING, f'{node.func.id}() created by the extension',
785
+ 'use the ones of the Dashboard: self.experiment_manager and self.state_manager', path, node.lineno))
786
+ return findings
787
+
788
+
789
+ def check_extensions(root: Path) -> list[Finding]:
790
+ """The rules on every module of the ``extensions`` folder of the package"""
791
+ folder = root / 'extensions'
792
+ findings = []
793
+ if folder.is_dir():
794
+ for path in _python_files(folder):
795
+ if not path.name.startswith('_'):
796
+ findings.extend(check_extension_source(path))
797
+ return findings
798
+
799
+
643
800
  def check_package_sources(package: str) -> list[Finding]:
644
801
  """All the static rules on an installed (or editable) plugin package: packaging, leftovers and plugin classes"""
645
802
  from pymodaq_utils.plugin_tools.checks import find_plugin_modules # no import cycle at module level
@@ -652,6 +809,7 @@ def check_package_sources(package: str) -> list[Finding]:
652
809
  findings.extend(check_file_names(root))
653
810
  findings.extend(check_leftovers(package, root, project))
654
811
  findings.extend(check_python_code(root))
812
+ findings.extend(check_extensions(root))
655
813
  for mod in find_plugin_modules(package):
656
814
  file = Path(importlib.util.find_spec(mod.import_path).origin)
657
815
  findings.extend(check_plugin_source(file, mod.kind, mod.class_name))
@@ -684,3 +842,4 @@ def check_os_classifiers(package_folder: Path, project: Path) -> list[Finding]:
684
842
  check_plugin_source = _honour_noqa(check_plugin_source)
685
843
  check_python_code = _honour_noqa(check_python_code)
686
844
  check_leftovers = _honour_noqa(check_leftovers)
845
+ check_extension_source = _honour_noqa(check_extension_source)
@@ -58,6 +58,8 @@ class $class_name(DAQ_Move_base):
58
58
  * the instrument (and models) this plugin was tested with
59
59
  * the drivers or libraries to install and where to find them
60
60
  * the specific attributes and parameters (see ``params``)
61
+
62
+ Documentation of the methods of a plugin: https://pymodaq.cnrs.fr/en/latest/developer_folder/instrument_plugins.html
61
63
  """
62
64
  is_multiaxes = False # TODO set to True if the controller drives several axes
63
65
  _axis_names = ['Axis1'] # TODO list (or dict name -> index) of the axes of the controller
@@ -92,6 +94,9 @@ class $class_name(DAQ_Move_base):
92
94
  ----------
93
95
  param: Parameter
94
96
  A given parameter (within settings) whose value has been changed by the user
97
+
98
+ See
99
+ https://pymodaq.cnrs.fr/en/latest/developer_folder/instrument_plugins.html#hardware-settings
95
100
  """
96
101
  # TODO apply the changes on the controller, for instance:
97
102
  # if param.name() == 'a_parameter_you_added_in_params':
@@ -128,7 +133,11 @@ class $class_name(DAQ_Move_base):
128
133
  raise NotImplementedError # TODO remove this line and close the communication with the controller
129
134
 
130
135
  def move_abs(self, value: DataActuator):
131
- """Move the actuator to the absolute target defined by value"""
136
+ """Move the actuator to the absolute target defined by value
137
+
138
+ PyMoDAQ then waits for the target to be reached at ``epsilon``, see
139
+ https://pymodaq.cnrs.fr/en/latest/developer_folder/instrument_plugins.html#value-reached
140
+ """
132
141
  value = self.check_bound(value) # if the user checked the bounds, they are applied here
133
142
  self.target_value = value
134
143
  value = self.set_position_with_scaling(value) # apply the scaling if the user specified one
@@ -137,7 +146,11 @@ class $class_name(DAQ_Move_base):
137
146
  self.emit_status(ThreadCommand('Update_Status', ['Some info you want to log']))
138
147
 
139
148
  def move_rel(self, value: DataActuator):
140
- """Move the actuator to the relative target defined by value"""
149
+ """Move the actuator to the relative target defined by value
150
+
151
+ PyMoDAQ then waits for the target to be reached at ``epsilon``, see
152
+ https://pymodaq.cnrs.fr/en/latest/developer_folder/instrument_plugins.html#value-reached
153
+ """
141
154
  value = self.check_bound(self.current_value + value) - self.current_value
142
155
  self.target_value = value + self.current_value
143
156
  value = self.set_position_relative_with_scaling(value)
@@ -146,10 +159,16 @@ class $class_name(DAQ_Move_base):
146
159
  self.emit_status(ThreadCommand('Update_Status', ['Some info you want to log']))
147
160
 
148
161
  def move_home(self):
149
- """Call the reference method of the controller"""
162
+ """Call the reference method of the controller, so that the reference position becomes the home (zero)
163
+
164
+ PyMoDAQ does not poll the position after a move_home: call ``self.move_done()`` when the homing is finished,
165
+ see
166
+ https://pymodaq.cnrs.fr/en/latest/developer_folder/instrument_plugins.html#moving-to-the-home-position
167
+ """
150
168
  raise NotImplementedError # TODO remove this line and call the homing of the controller below
151
- self.controller.home()
169
+ self.controller.home() # blocking version. For an asynchronous one, give a callback calling self.move_done()
152
170
  self.emit_status(ThreadCommand('Update_Status', ['Some info you want to log']))
171
+ self.move_done() # reads get_actuator_value() and signals the end of the move
153
172
 
154
173
  def stop_motion(self):
155
174
  """Stop the actuator and emit the move_done signal"""
@@ -186,6 +205,8 @@ class $class_name(DAQ_Viewer_base):
186
205
  * the instrument (and models) this plugin was tested with
187
206
  * the drivers or libraries to install and where to find them
188
207
  * the specific attributes and parameters (see ``params``)
208
+
209
+ Documentation of the methods of a plugin: https://pymodaq.cnrs.fr/en/latest/developer_folder/instrument_plugins.html
189
210
  """
190
211
  params = comon_parameters + [
191
212
  # TODO add here, as dicts, the settings of your instrument
@@ -203,6 +224,9 @@ class $class_name(DAQ_Viewer_base):
203
224
  ----------
204
225
  param: Parameter
205
226
  A given parameter (within detector_settings) whose value has been changed by the user
227
+
228
+ See
229
+ https://pymodaq.cnrs.fr/en/latest/developer_folder/instrument_plugins.html#hardware-settings
206
230
  """
207
231
  # TODO apply the changes on the controller, for instance:
208
232
  # if param.name() == 'a_parameter_you_added_in_params':
@@ -245,11 +269,17 @@ class $class_name(DAQ_Viewer_base):
245
269
  def grab_data(self, Naverage=1, **kwargs):
246
270
  """Start a grab from the detector
247
271
 
272
+ When the data are ready, emit them with dte_signal, see
273
+ https://pymodaq.cnrs.fr/en/latest/developer_folder/instrument_plugins.html#emission-of-data
274
+ If they are not ready when this method returns, see
275
+ https://pymodaq.cnrs.fr/en/latest/developer_folder/instrument_plugins.html#data-ready
276
+
248
277
  Parameters
249
278
  ----------
250
279
  Naverage: int
251
280
  Number of hardware averaging (if the hardware can average, set hardware_averaging = True in the class
252
- and implement it here)
281
+ and implement it here, see
282
+ https://pymodaq.cnrs.fr/en/latest/developer_folder/instrument_plugins.html#hardware-averaging)
253
283
  kwargs: dict
254
284
  others optionals arguments
255
285
  """
File without changes
File without changes
File without changes