pymodaq_utils 5.3.1__tar.gz → 5.3.2__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 (38) hide show
  1. {pymodaq_utils-5.3.1 → pymodaq_utils-5.3.2}/PKG-INFO +1 -1
  2. {pymodaq_utils-5.3.1 → pymodaq_utils-5.3.2}/src/pymodaq_utils/plugin_checks.py +11 -2
  3. {pymodaq_utils-5.3.1 → pymodaq_utils-5.3.2}/src/pymodaq_utils/plugin_rules.py +119 -2
  4. pymodaq_utils-5.3.2/src/pymodaq_utils/plugin_testing.py +22 -0
  5. pymodaq_utils-5.3.1/src/pymodaq_utils/plugin_testing.py +0 -69
  6. {pymodaq_utils-5.3.1 → pymodaq_utils-5.3.2}/.gitignore +0 -0
  7. {pymodaq_utils-5.3.1 → pymodaq_utils-5.3.2}/LICENSE +0 -0
  8. {pymodaq_utils-5.3.1 → pymodaq_utils-5.3.2}/README.rst +0 -0
  9. {pymodaq_utils-5.3.1 → pymodaq_utils-5.3.2}/pyproject.toml +0 -0
  10. {pymodaq_utils-5.3.1 → pymodaq_utils-5.3.2}/src/pymodaq_utils/__init__.py +0 -0
  11. {pymodaq_utils-5.3.1 → pymodaq_utils-5.3.2}/src/pymodaq_utils/abstract.py +0 -0
  12. {pymodaq_utils-5.3.1 → pymodaq_utils-5.3.2}/src/pymodaq_utils/array_manipulation.py +0 -0
  13. {pymodaq_utils-5.3.1 → pymodaq_utils-5.3.2}/src/pymodaq_utils/categorizing.py +0 -0
  14. {pymodaq_utils-5.3.1 → pymodaq_utils-5.3.2}/src/pymodaq_utils/config.py +0 -0
  15. {pymodaq_utils-5.3.1 → pymodaq_utils-5.3.2}/src/pymodaq_utils/enums.py +0 -0
  16. {pymodaq_utils-5.3.1 → pymodaq_utils-5.3.2}/src/pymodaq_utils/environment.py +0 -0
  17. {pymodaq_utils-5.3.1 → pymodaq_utils-5.3.2}/src/pymodaq_utils/factory.py +0 -0
  18. {pymodaq_utils-5.3.1 → pymodaq_utils-5.3.2}/src/pymodaq_utils/help.py +0 -0
  19. {pymodaq_utils-5.3.1 → pymodaq_utils-5.3.2}/src/pymodaq_utils/icon.ico +0 -0
  20. {pymodaq_utils-5.3.1 → pymodaq_utils-5.3.2}/src/pymodaq_utils/logger.py +0 -0
  21. {pymodaq_utils-5.3.1 → pymodaq_utils-5.3.2}/src/pymodaq_utils/math_utils.py +0 -0
  22. {pymodaq_utils-5.3.1 → pymodaq_utils-5.3.2}/src/pymodaq_utils/mysocket.py +0 -0
  23. {pymodaq_utils-5.3.1 → pymodaq_utils-5.3.2}/src/pymodaq_utils/packages.py +0 -0
  24. {pymodaq_utils-5.3.1 → pymodaq_utils-5.3.2}/src/pymodaq_utils/plugin_scaffold.py +0 -0
  25. {pymodaq_utils-5.3.1 → pymodaq_utils-5.3.2}/src/pymodaq_utils/resources/__init__.py +0 -0
  26. {pymodaq_utils-5.3.1 → pymodaq_utils-5.3.2}/src/pymodaq_utils/resources/config_template.toml +0 -0
  27. {pymodaq_utils-5.3.1 → pymodaq_utils-5.3.2}/src/pymodaq_utils/resources/hatch_build_plugins.py +0 -0
  28. {pymodaq_utils-5.3.1 → pymodaq_utils-5.3.2}/src/pymodaq_utils/serialize/__init__.py +0 -0
  29. {pymodaq_utils-5.3.1 → pymodaq_utils-5.3.2}/src/pymodaq_utils/serialize/factory.py +0 -0
  30. {pymodaq_utils-5.3.1 → pymodaq_utils-5.3.2}/src/pymodaq_utils/serialize/mysocket.py +0 -0
  31. {pymodaq_utils-5.3.1 → pymodaq_utils-5.3.2}/src/pymodaq_utils/serialize/serializer.py +0 -0
  32. {pymodaq_utils-5.3.1 → pymodaq_utils-5.3.2}/src/pymodaq_utils/serialize/serializer_legacy.py +0 -0
  33. {pymodaq_utils-5.3.1 → pymodaq_utils-5.3.2}/src/pymodaq_utils/serialize/utils.py +0 -0
  34. {pymodaq_utils-5.3.1 → pymodaq_utils-5.3.2}/src/pymodaq_utils/singleton.py +0 -0
  35. {pymodaq_utils-5.3.1 → pymodaq_utils-5.3.2}/src/pymodaq_utils/splash.png +0 -0
  36. {pymodaq_utils-5.3.1 → pymodaq_utils-5.3.2}/src/pymodaq_utils/units.py +0 -0
  37. {pymodaq_utils-5.3.1 → pymodaq_utils-5.3.2}/src/pymodaq_utils/utils.py +0 -0
  38. {pymodaq_utils-5.3.1 → pymodaq_utils-5.3.2}/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.1
3
+ Version: 5.3.2
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
@@ -34,7 +34,7 @@ import toml
34
34
  from pymodaq_utils.utils import get_entrypoints
35
35
 
36
36
  from pymodaq_utils.plugin_rules import (PLACEHOLDER_NAMES_RE, Finding, Severity, check_file_names, check_leftovers,
37
- check_plugin_source, check_pyproject, colorize, is_valid_unit, package_root,
37
+ check_plugin_source, check_python_code, check_pyproject, colorize, is_valid_unit, package_root,
38
38
  project_root, use_color)
39
39
 
40
40
  VIEWER_DIMS = ('0D', '1D', '2D', 'ND')
@@ -44,7 +44,7 @@ OPTIONAL_ENTRYPOINT_GROUPS = ('pymodaq.extensions', 'pymodaq.models', 'pymodaq.p
44
44
 
45
45
  # methods that have to be overridden by the plugin (they are abstract in the base classes, but these are not
46
46
  # enforced as the bases are QObjects)
47
- MANDATORY_MOVE_METHODS = ('ini_stage', 'get_actuator_value', 'stop_motion', 'close')
47
+ MANDATORY_MOVE_METHODS = ('ini_stage', 'get_actuator_value', 'close') # stop_motion has a default that does nothing
48
48
  MANDATORY_VIEWER_METHODS = ('ini_detector', 'grab_data', 'stop', 'close')
49
49
 
50
50
  # files generated by parser generators (pycparser/ply) that can end up within the plugin packages
@@ -365,6 +365,14 @@ def check_plugin_module(plugin_module: PluginModule) -> CheckResult:
365
365
  (result.warnings if e.environmental else result.problems).append(str(e))
366
366
  if loaded:
367
367
  result.problems = check_move_class(klass) if plugin_module.kind == 'move' else check_viewer_class(klass)
368
+ if plugin_module.kind == 'move':
369
+ from pymodaq.control_modules.move_utility_classes import DAQ_Move_base
370
+ if not _is_overridden(klass, DAQ_Move_base, 'stop_motion'):
371
+ result.findings.append(Finding(
372
+ 'PMQ311', Severity.WARNING,
373
+ 'stop_motion() is not overridden: the Stop of the actuator does nothing',
374
+ 'implement it with the stop command of your controller (or ignore this if it cannot be stopped)',
375
+ path, None))
368
376
  if path is not None:
369
377
  result.findings.extend(check_plugin_source(path, plugin_module.kind, plugin_module.class_name,
370
378
  static_fallback=not loaded))
@@ -444,6 +452,7 @@ def check_package_sources(package: str, project: Optional[Path] = None) -> Check
444
452
  result.findings.extend(check_pyproject(package, project))
445
453
  result.findings.extend(check_file_names(root))
446
454
  result.findings.extend(check_leftovers(package, root, project))
455
+ result.findings.extend(check_python_code(root))
447
456
  return result
448
457
 
449
458
 
@@ -25,6 +25,7 @@ import importlib.util
25
25
  import os
26
26
  import re
27
27
  import sys
28
+ import warnings
28
29
  from dataclasses import dataclass
29
30
  from enum import Enum
30
31
  from pathlib import Path
@@ -42,9 +43,20 @@ PLACEHOLDER_AUTHORS = ('Name Surname', 'myname@test.fr')
42
43
  PLACEHOLDER_NAMES_RE = re.compile(r'python_wrapper_file_of_your_instrument|PythonWrapperObjectOfYourInstrument|'
43
44
  r'your_method_to_\w+|a_method_or_atttribute_to_check_if_init')
44
45
 
45
- MANDATORY_MOVE_METHODS = ('ini_stage', 'get_actuator_value', 'stop_motion', 'close')
46
+ MANDATORY_MOVE_METHODS = ('ini_stage', 'get_actuator_value', 'close') # stop_motion has a default that does nothing
46
47
  MANDATORY_VIEWER_METHODS = ('ini_detector', 'grab_data', 'stop', 'close')
47
48
 
49
+ # hooks of the plugins that were renamed: PyMoDAQ still calls the old ones, with a deprecation warning
50
+ DEPRECATED_HOOKS = {'check_position': 'get_actuator_value', 'move_Abs': 'move_abs', 'move_Rel': 'move_rel',
51
+ 'move_Home': 'move_home'}
52
+ # attributes of the actuators that were renamed
53
+ DEPRECATED_ATTRIBUTES = {'current_position': 'current_value', 'target_position': 'target_value'}
54
+ # names of the groups of settings that were renamed: PyMoDAQ only translates them in settings[...], not in
55
+ # settings.child(...)
56
+ LEGACY_SETTINGS = {'multiaxes': 'controller', 'multi_status': 'controller_status'}
57
+ WINDOWS_ONLY_IMPORTS = {'winreg', 'msvcrt', '_winreg', 'win32api', 'win32com'}
58
+ WINDOWS_PATH_RE = re.compile(r'^[A-Za-z]:[\\/]')
59
+
48
60
  # features of [features] in pyproject.toml -> folders (relative to the package) holding the corresponding code
49
61
  FEATURE_FOLDERS = {'instruments': ('daq_move_plugins', 'daq_viewer_plugins'),
50
62
  'extensions': ('extensions',),
@@ -194,6 +206,65 @@ def check_file_names(root: Path) -> list[Finding]:
194
206
  if '__pycache__' not in path.parts and path.stem not in IGNORED_MODULES and not path.stem.isidentifier()]
195
207
 
196
208
 
209
+ def _guarded(node: ast.AST, parents: dict) -> bool:
210
+ """Whether a node is inside a ``try`` with handlers or an ``if`` testing the platform"""
211
+ parent = parents.get(node)
212
+ while parent is not None:
213
+ if isinstance(parent, ast.Try) and parent.handlers:
214
+ return True
215
+ if isinstance(parent, ast.If) and re.search(r'platform|os\.name|\'nt\'|win32|system\(', ast.dump(parent.test)):
216
+ return True
217
+ parent = parents.get(parent)
218
+ return False
219
+
220
+
221
+ def check_python_code(root: Path) -> list[Finding]:
222
+ """Rules on all the python files of the package (plugins and hardware wrappers)
223
+
224
+ * PMQ315: invalid escape sequence in a string (``"C:\\Program Files"``): a warning of python since 3.6, a
225
+ ``SyntaxWarning`` since 3.12, that will be an error
226
+ * 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
228
+ """
229
+ findings = []
230
+ for path in _python_files(root):
231
+ source = path.read_text(errors='replace')
232
+ with warnings.catch_warnings(record=True) as caught:
233
+ warnings.simplefilter('always')
234
+ try:
235
+ tree = ast.parse(source, filename=str(path))
236
+ except SyntaxError:
237
+ continue # reported by the other rules
238
+ for warning in caught:
239
+ if 'invalid escape sequence' in str(warning.message):
240
+ findings.append(Finding('PMQ315', Severity.WARNING, str(warning.message),
241
+ "use a raw string (r'...') or double the backslash", path, warning.lineno))
242
+ parents = {child: node for node in ast.walk(tree) for child in ast.iter_child_nodes(node)}
243
+ windows = []
244
+ for node in ast.walk(tree):
245
+ what = None
246
+ if isinstance(node, ast.ImportFrom) and node.module == 'ctypes' and any(a.name == 'windll' for a in
247
+ node.names):
248
+ what = 'ctypes.windll'
249
+ elif isinstance(node, ast.Import) and any(a.name.split('.')[0] in WINDOWS_ONLY_IMPORTS
250
+ for a in node.names):
251
+ what = next(a.name for a in node.names if a.name.split('.')[0] in WINDOWS_ONLY_IMPORTS)
252
+ elif isinstance(node, ast.ImportFrom) and (node.module or '').split('.')[0] in WINDOWS_ONLY_IMPORTS:
253
+ what = node.module
254
+ elif isinstance(node, ast.Constant) and isinstance(node.value, str) and WINDOWS_PATH_RE.match(node.value):
255
+ what = f'the path {node.value!r}'
256
+ if what is not None and not _guarded(node, parents):
257
+ windows.append((node.lineno, what))
258
+ if windows:
259
+ line, what = windows[0]
260
+ more = f' (and {len(windows) - 1} more in this file)' if len(windows) > 1 else ''
261
+ findings.append(Finding('PMQ317', Severity.WARNING, f'only works on Windows: {what}{more}',
262
+ 'import it lazily, in the method that needs it, or guard it with '
263
+ 'sys.platform, so that the plugin can be imported on the other systems',
264
+ path, line))
265
+ return findings
266
+
267
+
197
268
  # -------------------------------------------------------------------------------------------------------------------
198
269
  # PMQ1xx: packaging
199
270
  # -------------------------------------------------------------------------------------------------------------------
@@ -406,13 +477,16 @@ def check_plugin_source(path: Path, kind: str, class_name: str, static_fallback:
406
477
  methods = {n.name: n for n in cls.body if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))}
407
478
  if kind == 'move':
408
479
  findings.extend(_check_move_source(path, cls, attrs, static_fallback))
480
+ findings.extend(_check_deprecated_api(path, tree, methods))
409
481
  mandatory = MANDATORY_MOVE_METHODS
410
482
  else:
411
483
  mandatory = MANDATORY_VIEWER_METHODS
484
+ findings.extend(_check_legacy_settings(path, tree))
412
485
  simple_bases = all(b in ('DAQ_Move_base', 'DAQ_Viewer_base') for b in _base_names(cls))
413
486
  if static_fallback and simple_bases:
414
487
  for meth in mandatory:
415
- if meth not in methods:
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):
416
490
  findings.append(Finding('PMQ306', Severity.ERROR, f'{class_name} should define {meth}()',
417
491
  'implement it (see the template)', path, cls.lineno))
418
492
  init = methods.get('ini_stage' if kind == 'move' else 'ini_detector')
@@ -461,6 +535,48 @@ def _check_move_source(path: Path, cls: ast.ClassDef, attrs: dict, static_fallba
461
535
  return findings
462
536
 
463
537
 
538
+ def _check_deprecated_api(path: Path, tree: ast.Module, methods: dict) -> list[Finding]:
539
+ """PMQ312-314: parts of the API of an actuator that were renamed or changed"""
540
+ findings = []
541
+ for old, new in DEPRECATED_HOOKS.items():
542
+ if old in methods:
543
+ findings.append(Finding('PMQ312', Severity.WARNING, f'{old}() is deprecated, it is still called but with '
544
+ f'a warning', f'rename it {new}()', path, methods[old].lineno))
545
+ for attr, new in DEPRECATED_ATTRIBUTES.items():
546
+ uses = [n for n in ast.walk(tree) if isinstance(n, ast.Attribute) and n.attr == attr and
547
+ isinstance(n.value, ast.Name) and n.value.id == 'self']
548
+ if uses:
549
+ more = f' ({len(uses)} times in this file)' if len(uses) > 1 else ''
550
+ findings.append(Finding('PMQ313', Severity.WARNING, f"'{attr}' is deprecated{more}", f"use '{new}'",
551
+ path, min(n.lineno for n in uses)))
552
+ # comon_parameters of the actuators is a function since pymodaq 5 (it was a list), the one of the viewers a list
553
+ names = {a.asname or a.name for n in ast.walk(tree) if isinstance(n, ast.ImportFrom) and
554
+ (n.module or '').endswith('move_utility_classes') for a in n.names if a.name == 'comon_parameters'}
555
+ if names:
556
+ called = {id(n.func) for n in ast.walk(tree) if isinstance(n, ast.Call)}
557
+ for node in ast.walk(tree):
558
+ if isinstance(node, ast.Name) and node.id in names and isinstance(node.ctx, ast.Load) and \
559
+ id(node) not in called:
560
+ findings.append(Finding('PMQ314', Severity.ERROR, "'comon_parameters' is used as a list but it is "
561
+ "a function", "call it: comon_parameters_fun(is_multiaxes, axis_names=..., "
562
+ "epsilon=...), or comon_parameters(epsilon)", path, node.lineno))
563
+ return findings
564
+
565
+
566
+ def _check_legacy_settings(path: Path, tree: ast.Module) -> list[Finding]:
567
+ """PMQ316: names of groups of settings that were renamed"""
568
+ hits = [n for n in ast.walk(tree) if isinstance(n, ast.Constant) and n.value in LEGACY_SETTINGS]
569
+ if not hits:
570
+ return []
571
+ first = min(hits, key=lambda n: n.lineno)
572
+ more = f' ({len(hits)} times in this file)' if len(hits) > 1 else ''
573
+ return [Finding('PMQ316', Severity.WARNING, f"settings group '{first.value}' was renamed "
574
+ 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,
577
+ first.lineno)]
578
+
579
+
464
580
  def check_package_sources(package: str) -> list[Finding]:
465
581
  """All the static rules on an installed (or editable) plugin package: packaging, leftovers and plugin classes"""
466
582
  from pymodaq_utils.plugin_checks import find_plugin_modules # no import cycle at module level
@@ -472,6 +588,7 @@ def check_package_sources(package: str) -> list[Finding]:
472
588
  findings = check_pyproject(package, project) if project is not None else []
473
589
  findings.extend(check_file_names(root))
474
590
  findings.extend(check_leftovers(package, root, project))
591
+ findings.extend(check_python_code(root))
475
592
  for mod in find_plugin_modules(package):
476
593
  file = Path(importlib.util.find_spec(mod.import_path).origin)
477
594
  findings.extend(check_plugin_source(file, mod.kind, mod.class_name))
@@ -0,0 +1,22 @@
1
+ """Moved to :mod:`pymodaq.utils.plugin_testing`, with the tools to test the behaviour of a plugin.
2
+
3
+ The pytest mixin was in ``pymodaq_utils`` to avoid the initialization of ``pymodaq`` when importing it. As it is used
4
+ in tests that import the plugins (hence ``pymodaq``) anyway, it now sits with the other plugin testing tools. This
5
+ module is kept for the plugin repositories written for PyMoDAQ 5.3.1 and will be removed in a later version.
6
+
7
+ .. versionadded:: 5.3.1
8
+ .. deprecated:: 5.3.2
9
+ Use ``from pymodaq.utils.plugin_testing import PluginPackageChecks``.
10
+ """
11
+ from pymodaq_utils.warnings import deprecation_msg
12
+
13
+
14
+ def __getattr__(name):
15
+ if name == 'PluginPackageChecks':
16
+ try:
17
+ from pymodaq.utils.plugin_testing import PluginPackageChecks
18
+ except ImportError as e: # pragma: no cover
19
+ raise ImportError('PluginPackageChecks needs pymodaq >= 5.3.2: pymodaq.utils.plugin_testing') from e
20
+ deprecation_msg('pymodaq_utils.plugin_testing is deprecated, use pymodaq.utils.plugin_testing instead')
21
+ return PluginPackageChecks
22
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -1,69 +0,0 @@
1
- """Pytest mixin applying the acceptance checks of :mod:`pymodaq_utils.plugin_checks` to a plugin package.
2
-
3
- It is in ``pymodaq_utils`` (and not in ``pymodaq``) so that importing it does not start the whole PyMoDAQ
4
- initialization. Use it in a test module of a plugin repository::
5
-
6
- from pymodaq_utils.plugin_testing import PluginPackageChecks
7
-
8
- class TestMyPlugin(PluginPackageChecks):
9
- package_name = 'pymodaq_plugins_myinstrument' # optional, found from the package folder if omitted
10
- fail_on = 'error' # or 'warning', 'todo'
11
- strict_imports = True
12
-
13
- .. versionadded:: 5.3.1
14
- """
15
- from pathlib import Path
16
- from typing import Optional
17
-
18
- import pytest
19
-
20
- from pymodaq_utils.plugin_checks import (VIEWER_DIMS, PluginModule, check_package_layout, check_package_sources,
21
- check_plugin_module, find_plugin_modules, guess_package_name)
22
-
23
-
24
- class PluginPackageChecks:
25
- """Mixin to subclass in a test module of a plugin repository, see the module documentation.
26
-
27
- Test classes are parametrized per plugin module so that every failure is reported individually.
28
- """
29
- package_name: Optional[str] = None
30
- fail_on = 'error' # 'error', 'warning' or 'todo' (the unfinished parts: TODO comments, placeholders...): the
31
- # findings of the static rules of this level and above fail the tests
32
- strict_imports = False # if True, a module that cannot be imported because of a missing third party module or
33
- # SDK fails instead of being skipped (recommended in the CI of a plugin as its dependencies are installed there)
34
-
35
- @pytest.fixture
36
- def package(self, request) -> str:
37
- return self.package_name or guess_package_name(Path(request.fspath).parent)
38
-
39
- def pytest_generate_tests(self, metafunc):
40
- if 'plugin_module' not in metafunc.fixturenames:
41
- return
42
- package = self.package_name or guess_package_name(Path(str(metafunc.definition.fspath)).parent)
43
- kinds = {'move': ('move',), 'viewer': VIEWER_DIMS}
44
- wanted = metafunc.function.__name__
45
- modules = [m for m in find_plugin_modules(package)
46
- if m.kind in (kinds['move'] if '_move_' in wanted else kinds['viewer'])]
47
- metafunc.parametrize('plugin_module', modules, ids=str)
48
-
49
- def test_package_layout(self, package):
50
- problems = check_package_layout(package)
51
- assert not problems, '\n'.join(problems)
52
-
53
- def test_move_plugin(self, plugin_module):
54
- self._assert_module_ok(plugin_module)
55
-
56
- def test_viewer_plugin(self, plugin_module):
57
- self._assert_module_ok(plugin_module)
58
-
59
- def test_package_sources(self, package):
60
- result = check_package_sources(package)
61
- messages = result.failing(self.fail_on)
62
- assert not messages, '\n'.join(messages)
63
-
64
- def _assert_module_ok(self, plugin_module: PluginModule):
65
- result = check_plugin_module(plugin_module)
66
- messages = result.failing(self.fail_on, self.strict_imports)
67
- assert not messages, '\n'.join(messages)
68
- if result.warnings:
69
- pytest.skip('; '.join(result.warnings))
File without changes
File without changes
File without changes