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.
- {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/PKG-INFO +1 -1
- {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/array_manipulation.py +1 -1
- {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/math_utils.py +3 -1
- {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/plugin_tools/checks.py +2 -1
- pymodaq_utils-5.3.7/src/pymodaq_utils/plugin_tools/extension_checks.py +102 -0
- {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/plugin_tools/platforms.py +72 -0
- {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/plugin_tools/rules.py +163 -4
- {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/plugin_tools/scaffold.py +35 -5
- {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/.gitignore +0 -0
- {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/LICENSE +0 -0
- {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/README.rst +0 -0
- {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/pyproject.toml +0 -0
- {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/__init__.py +0 -0
- {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/abstract.py +0 -0
- {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/categorizing.py +0 -0
- {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/config.py +0 -0
- {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/enums.py +0 -0
- {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/environment.py +0 -0
- {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/factory.py +0 -0
- {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/help.py +0 -0
- {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/icon.ico +0 -0
- {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/logger.py +0 -0
- {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/mysocket.py +0 -0
- {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/packages.py +0 -0
- {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/plugin_checks.py +0 -0
- {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/plugin_rules.py +0 -0
- {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/plugin_scaffold.py +0 -0
- {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/plugin_testing.py +0 -0
- {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/plugin_tools/__init__.py +0 -0
- {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/plugin_tools/contract.py +0 -0
- {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/resources/__init__.py +0 -0
- {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/resources/config_template.toml +0 -0
- {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/resources/hatch_build_plugins.py +0 -0
- {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/serialize/__init__.py +0 -0
- {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/serialize/factory.py +0 -0
- {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/serialize/mysocket.py +0 -0
- {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/serialize/serializer.py +0 -0
- {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/serialize/serializer_legacy.py +0 -0
- {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/serialize/utils.py +0 -0
- {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/singleton.py +0 -0
- {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/splash.png +0 -0
- {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/units.py +0 -0
- {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/utils.py +0 -0
- {pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/warnings.py +0 -0
|
@@ -379,7 +379,7 @@ def min_ind(x, axis=None):
|
|
|
379
379
|
|
|
380
380
|
|
|
381
381
|
if __name__ == '__main__': # pragma: no cover
|
|
382
|
-
from
|
|
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
|
|
241
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/resources/config_template.toml
RENAMED
|
File without changes
|
{pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/resources/hatch_build_plugins.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pymodaq_utils-5.3.6 → pymodaq_utils-5.3.7}/src/pymodaq_utils/serialize/serializer_legacy.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|