Source code for pymodaq_utils.plugin_tools.rules

"""Static rules to guide the development of a PyMoDAQ instrument plugin (``pymodaq_plugins_*``).

The rules read the *source files* of a plugin package (``pyproject.toml``, python modules): nothing is imported, so
they also work when the instrument SDK or other dependencies are not installed. Each rule produces
:class:`Finding` objects with a stable code, a severity, a location and a hint about how to fix it.

Severities:

* ``error``: the plugin will not work or will not be seen by PyMoDAQ
* ``warning``: deprecated or suspicious, should be fixed
* ``todo``: the plugin is not finished (leftover of the template: TODO comments, placeholders, ``NotImplementedError``)

They are used by :mod:`pymodaq_utils.plugin_tools.checks` but can be called directly::

    from pymodaq_utils.plugin_tools.rules import check_package_sources
    for finding in check_package_sources('pymodaq_plugins_xxxx'):
        print(finding)

.. versionadded:: 5.3.1
"""
from __future__ import annotations

import ast
import functools
import importlib.util
import os
import re
import sys
import warnings
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import Optional

import toml

from pymodaq_utils.plugin_tools.contract import legacy_names, mandatory_methods
from pymodaq_utils.plugin_tools import platforms as _platforms

DOC = 'https://pymodaq.cnrs.fr/en/latest/developer_folder/instrument_plugins.html'
TEMPLATE_NAME = 'pymodaq_plugins_template'

IGNORED_MODULES = ('lextab', 'yacctab')  # files generated by parser generators (pycparser/ply)

PLACEHOLDER_DESCRIPTION = 'some word about your plugin'
PLACEHOLDER_AUTHORS = ('Name Surname', 'myname@test.fr')
PLACEHOLDER_NAMES_RE = re.compile(r'python_wrapper_file_of_your_instrument|PythonWrapperObjectOfYourInstrument|'
                                  r'your_method_to_\w+|a_method_or_atttribute_to_check_if_init')

# defined once in pymodaq_utils.plugin_tools.contract
MANDATORY_MOVE_METHODS = mandatory_methods('move')
MANDATORY_VIEWER_METHODS = mandatory_methods('viewer')

# hooks of the plugins that were renamed: PyMoDAQ still calls the old ones, with a deprecation warning
DEPRECATED_HOOKS = {'check_position': 'get_actuator_value', 'move_Abs': 'move_abs', 'move_Rel': 'move_rel',
                    'move_Home': 'move_home'}
# attributes of the actuators that were renamed
DEPRECATED_ATTRIBUTES = {'current_position': 'current_value', 'target_position': 'target_value'}
# names of the groups of settings that were renamed: PyMoDAQ only translates them in settings[...], not in
# settings.child(...)
LEGACY_SETTINGS = {'multiaxes': 'controller', 'multi_status': 'controller_status'}
WINDOWS_ONLY_IMPORTS = {'winreg', 'msvcrt', '_winreg', 'win32api', 'win32com'}
WINDOWS_PATH_RE = re.compile(r'^[A-Za-z]:[\\/]')

# modules of pymodaq that only remain as shims importing from their new location (with a deprecation warning)
DEPRECATED_IMPORTS = {'pymodaq.extensions.custom_ext': 'pymodaq.utils.custom_ext',
                      'pymodaq.extensions.h5browser': 'pymodaq_gui.h5modules.h5browser',
                      'pymodaq.utils.math_utils': 'pymodaq_utils.math_utils',
                      'pymodaq.utils.array_manipulation': 'pymodaq_utils.array_manipulation',
                      'pymodaq.utils.parameter': 'pymodaq_gui.parameter',
                      'pymodaq.utils.messenger': 'pymodaq_gui.messenger',
                      'pymodaq.scripting': 'pymodaq_scripting'}
# hooks of the extensions that were renamed (the base class still calls the old ones)
DEPRECATED_EXTENSION_HOOKS = {'setup_docks': 'setup_docks_and_widgets', 'setup_menu': 'setup_menus_and_toolbars'}
# managers that an extension gets from the Dashboard: it should not create its own
# (its ModulesManager is the exception: the base class creates it for the extension)
DASHBOARD_OWNED = ('ExperimentManager', 'StateManager')

# features of [features] in pyproject.toml -> folders (relative to the package) holding the corresponding code
FEATURE_FOLDERS = {'instruments': ('daq_move_plugins', 'daq_viewer_plugins'),
                   'extensions': ('extensions',),
                   'models': ('models',),
                   'h5exporters': ('exporters',),
                   'scanners': ('scanners',)}


ANSI_STYLES = {'bold': '1', 'dim': '2', 'red': '31', 'green': '32', 'yellow': '33', 'cyan': '36'}


[docs] def colorize(text: str, style: str, enabled: bool = True) -> str: """Wrap a text in the ANSI escape codes of a style (bold, dim, red, green, yellow, cyan) if enabled""" if not enabled or not text: return text return f'\033[{ANSI_STYLES[style]}m{text}\033[0m'
[docs] def use_color(mode: str = 'auto', stream=None) -> bool: """Whether to color the output: mode is 'always', 'never' or 'auto' ``auto`` colors if the stream (stdout by default) is a terminal and the NO_COLOR environment variable is not set, or if FORCE_COLOR is set (see https://no-color.org). """ if mode == 'never': return False if mode == 'auto': if os.environ.get('NO_COLOR'): return False stream = stream or sys.stdout if not (os.environ.get('FORCE_COLOR') or (hasattr(stream, 'isatty') and stream.isatty())): return False if sys.platform == 'win32': os.system('') # enables the processing of the escape codes in the console return True
[docs] class Severity(str, Enum): ERROR = 'error' WARNING = 'warning' TODO = 'todo'
[docs] @dataclass(frozen=True) class Finding: """One thing found by a rule Attributes ---------- code: str stable identifier of the rule, PMQ1xx: packaging, PMQ2xx: unfinished plugin, PMQ3xx: plugin classes severity: Severity message: str hint: str how to fix it path: Path file concerned line: int line concerned column: int column concerned """ code: str severity: Severity message: str hint: str = '' path: Optional[Path] = None line: Optional[int] = None column: Optional[int] = None @property def location(self) -> str: if self.path is None: return '' name = self._path_in_package() if not self.line: return name return f'{name}:{self.line}' + (f':{self.column}' if self.column else '') def _path_in_package(self) -> str: """Path of the file from the folder of the plugin package (``hardware/driver.py``), its name if outside""" for parent in self.path.parents: if parent.name.startswith('pymodaq_plugins_'): return self.path.relative_to(parent).as_posix() return self.path.name
[docs] def format(self, color: bool = False) -> str: """Text of the finding, with colors for a terminal if ``color``""" style = {Severity.ERROR: 'red', Severity.WARNING: 'yellow', Severity.TODO: 'cyan'}[self.severity] where = f' {self.location}' if self.location else '' hint = colorize(f' -> {self.hint}', 'dim', color) if self.hint else '' return (f'{colorize(self.code, "bold", color)} {colorize(f"[{self.severity.value}]", style, color)}' f'{where}: {self.message}{hint}')
def __str__(self) -> str: return self.format()
[docs] def is_valid_unit(unit: str) -> bool: """Whether a unit is known from pint (with the units of PyMoDAQ if pymodaq_data is installed)""" try: from pymodaq_data import Unit except ImportError: # only pymodaq_utils is installed from pint import UnitRegistry Unit = UnitRegistry().Unit try: Unit(unit) except Exception: return False return True
[docs] def package_root(package: str) -> Optional[Path]: """Folder of the python package, found without importing it""" spec = importlib.util.find_spec(package) if spec is None or not spec.submodule_search_locations: return None return Path(list(spec.submodule_search_locations)[0])
[docs] def project_root(package: str) -> Optional[Path]: """Folder holding the pyproject.toml of the plugin: only exists for a source tree or an editable install""" root = package_root(package) for folder in (root.parents if root is not None else []): pyproject = folder / 'pyproject.toml' if pyproject.is_file(): try: name = toml.load(pyproject).get('project', {}).get('name', '') except toml.TomlDecodeError: return folder return folder if name.replace('-', '_').lower() == package else None return None
[docs] def syntax_error_finding(code: str, path: Path, error: SyntaxError) -> Finding: """Finding with the location (line and column) and the offending line of a syntax error""" text = (error.text or '').strip() hint = (f'"{text}" ' if text else '') + '(the other checks on this file are skipped until it is fixed)' return Finding(code, Severity.ERROR, f'syntax error: {error.msg}', hint, path, error.lineno, error.offset or None)
def _python_files(root: Path): """The python modules of the package: the generated files and the files that cannot be imported are left out""" for path in sorted(root.rglob('*.py')): if path.stem not in IGNORED_MODULES and '__pycache__' not in path.parts and path.stem.isidentifier(): yield path
[docs] def check_file_names(root: Path) -> list[Finding]: """Python files whose name is not a valid module name: they cannot be imported and should not be shipped""" return [Finding('PMQ112', Severity.WARNING, f"'{path.name}' is not a valid python module name", 'delete or rename it: it is often a conflicted copy made by a file synchronization tool ' '(Dropbox, OneDrive, Syncthing...) or an editor backup', path) for path in sorted(root.rglob('*.py')) if '__pycache__' not in path.parts and path.stem not in IGNORED_MODULES and not path.stem.isidentifier()]
def _guarded(node: ast.AST, parents: dict) -> bool: """Whether a node is inside a ``try`` with handlers or an ``if`` testing the platform""" parent = parents.get(node) while parent is not None: if isinstance(parent, ast.Try) and parent.handlers: return True if isinstance(parent, ast.If) and re.search(r'platform|os\.name|\'nt\'|win32|system\(', ast.dump(parent.test)): return True parent = parents.get(parent) return False
[docs] def check_python_code(root: Path) -> list[Finding]: """Rules on all the python files of the package (plugins and hardware wrappers) * PMQ315: invalid escape sequence in a string (``"C:\\Program Files"``): a warning of python since 3.6, a ``SyntaxWarning`` since 3.12, that will be an error * PMQ317: code that only works on Windows and is executed when the module is imported (``windll``, ``winreg``, path with a drive letter): the plugin cannot be imported, nor listed, on the other systems. Not reported in the modules that only the instruments declaring ``supported_platforms = ('win32',)`` use """ findings = [] windows_only = _platforms.windows_only_files(root) for path in _python_files(root): source = path.read_text(errors='replace') with warnings.catch_warnings(record=True) as caught: warnings.simplefilter('always') try: tree = ast.parse(source, filename=str(path)) except SyntaxError: continue # reported by the other rules for warning in caught: if 'invalid escape sequence' in str(warning.message): findings.append(Finding('PMQ315', Severity.WARNING, str(warning.message), "use a raw string (r'...') or double the backslash", path, warning.lineno)) parents = {child: node for node in ast.walk(tree) for child in ast.iter_child_nodes(node)} windows = [] for node in ast.walk(tree): what = None if isinstance(node, ast.ImportFrom) and node.module == 'ctypes' and any(a.name == 'windll' for a in node.names): what = 'ctypes.windll' elif isinstance(node, ast.Import) and any(a.name.split('.')[0] in WINDOWS_ONLY_IMPORTS for a in node.names): what = next(a.name for a in node.names if a.name.split('.')[0] in WINDOWS_ONLY_IMPORTS) elif isinstance(node, ast.ImportFrom) and (node.module or '').split('.')[0] in WINDOWS_ONLY_IMPORTS: what = node.module elif isinstance(node, ast.Constant) and isinstance(node.value, str) and WINDOWS_PATH_RE.match(node.value): what = f'the path {node.value!r}' if what is not None and not _guarded(node, parents): windows.append((node.lineno, what)) if windows and path not in windows_only: line, what = windows[0] more = f' (and {len(windows) - 1} more in this file)' if len(windows) > 1 else '' findings.append(Finding('PMQ317', Severity.WARNING, f'only works on Windows: {what}{more}', 'import it lazily, in the method that needs it, or guard it with ' 'sys.platform, so that the plugin can be imported on the other systems', path, line)) return findings
# ------------------------------------------------------------------------------------------------------------------- # PMQ1xx: packaging # -------------------------------------------------------------------------------------------------------------------
[docs] def check_pyproject(package: str, project: Path) -> list[Finding]: """Rules on the ``pyproject.toml`` and the layout of the project""" path = project / 'pyproject.toml' try: content = toml.load(path) except toml.TomlDecodeError as e: return [Finding('PMQ100', Severity.ERROR, f'pyproject.toml cannot be parsed: {e}', path=path)] proj = content.get('project', {}) findings = [] def add(code, severity, message, hint='', needle=None): line = None if needle: for i, text in enumerate(path.read_text().splitlines(), 1): if needle in text: line = i break findings.append(Finding(code, severity, message, hint, path, line)) name = proj.get('name', '') normalized = name.replace('-', '_').lower() if not re.match(r'^pymodaq_plugins_\w+$', normalized): add('PMQ101', Severity.ERROR, f"project name '{name}' does not follow the convention", 'name it pymodaq_plugins_<manufacturer or topic>', 'name =') elif normalized == TEMPLATE_NAME and package != TEMPLATE_NAME: add('PMQ102', Severity.TODO, 'the project is still named after the template', 'set the name of your plugin in [project] name and rename the folder under src/ accordingly', 'name =') if normalized != package: add('PMQ103', Severity.ERROR, f"project name '{name}' and package folder '{package}' differ", 'rename the package folder under src/ (and its imports) or the project name', 'name =') if TEMPLATE_NAME in content.get('urls', {}).get('package-url', '') and package != TEMPLATE_NAME: add('PMQ104', Severity.TODO, '[urls] package-url still points to the template repository', 'use the url of your plugin repository', 'package-url') if proj.get('description', '') == PLACEHOLDER_DESCRIPTION: add('PMQ105', Severity.TODO, 'the description is still the template placeholder', 'describe the instruments of your plugin in a few words', 'description') for key in ('authors', 'maintainers'): if any(a.get('name') == PLACEHOLDER_AUTHORS[0] or a.get('email') == PLACEHOLDER_AUTHORS[1] for a in proj.get(key, [])): add('PMQ106', Severity.TODO, f'{key} still hold the template placeholder', f'fill the {key} with your name and email', f'{key} =') if not any(dep.replace(' ', '').lower().startswith('pymodaq') for dep in proj.get('dependencies', [])): add('PMQ107', Severity.WARNING, 'no pymodaq package in the dependencies', "add for instance 'pymodaq>=5.2.0' to [project] dependencies", 'dependencies') dynamic = proj.get('dynamic', []) if 'entry-points' not in dynamic and not proj.get('entry-points'): add('PMQ108', Severity.ERROR, 'no entry points are defined: PyMoDAQ will not find the plugin', "keep dynamic = ['version', 'urls', 'entry-points'] and the [tool.hatch.metadata.hooks.custom] table " "of the template", 'dynamic') features = content.get('features', {}) pkg_root = project / 'src' / package if (project / 'src' / package).is_dir() else project / package for feature, folders in FEATURE_FOLDERS.items(): has_code = any(f for folder in folders for f in _code_files(pkg_root / folder)) declared = bool(features.get(feature, False)) if has_code and not declared: add('PMQ109', Severity.WARNING, f"[features] {feature} is false but the package has code in {'/'.join(folders)}", f'set {feature} = true or its entry points will not be generated and PyMoDAQ will not see it', f'{feature} =') elif declared and not has_code: add('PMQ110', Severity.WARNING, f'[features] {feature} is true but nothing was found in {"/".join(folders)}', f'set {feature} = false if the plugin has no such feature', f'{feature} =') for filename in ('README.rst', 'LICENSE'): if not (project / filename).is_file(): findings.append(Finding('PMQ111', Severity.WARNING, f'{filename} is missing', 'add it at the project root', project)) return findings
def _code_files(folder: Path): if not folder.is_dir(): return [] return [p for p in folder.rglob('*.py') if p.stem != '__init__' and p.stem not in IGNORED_MODULES] # ------------------------------------------------------------------------------------------------------------------- # PMQ2xx: unfinished plugin (leftovers of the template) # ------------------------------------------------------------------------------------------------------------------- _TODO_RE = re.compile(r'(?i:#\s*todo\b)|\bTODO\b')
[docs] def check_leftovers(package: str, root: Path, project: Optional[Path] = None) -> list[Finding]: """Template leftovers: TODO comments, placeholders, ``NotImplementedError``, example modules""" findings = [] files = list(_python_files(root)) if project is not None: files.append(project / 'pyproject.toml') for path in files: if not path.is_file(): continue for i, text in enumerate(path.read_text(errors='replace').splitlines(), 1): if path.suffix == '.py' and _TODO_RE.search(text) and not text.strip().startswith('#!'): comment = text.strip().lstrip('#').strip() findings.append(Finding('PMQ201', Severity.TODO, comment[:90], 'complete it, then remove the TODO', path, i)) elif path.suffix == '.toml' and re.search(r'#\s*todo\b', text, re.IGNORECASE): findings.append(Finding('PMQ201', Severity.TODO, text.split('#', 1)[1].strip()[:90], 'complete it, then remove the TODO', path, i)) if path.suffix == '.py' and PLACEHOLDER_NAMES_RE.search(text) and package != TEMPLATE_NAME: findings.append(Finding('PMQ203', Severity.TODO, f'placeholder left from the template: ' f'{PLACEHOLDER_NAMES_RE.search(text).group(0)}', 'replace it with the wrapper and methods of your instrument', path, i)) if path.suffix == '.py': try: tree = ast.parse(path.read_text(errors='replace'), filename=str(path)) except SyntaxError as e: findings.append(syntax_error_finding('PMQ200', path, e)) continue for node in ast.walk(tree): if isinstance(node, ast.Raise) and _is_name(node.exc, 'NotImplementedError'): findings.append(Finding('PMQ202', Severity.TODO, 'a method still raises NotImplementedError', 'implement the method and remove this line', path, node.lineno)) if package != TEMPLATE_NAME: for path in _code_files(root): if path.stem.endswith('_Template') and re.match(r'daq_(move|\dDviewer|NDviewer)_Template$', path.stem): findings.append(Finding('PMQ204', Severity.TODO, 'example module of the template still present', 'rename it to your instrument or delete it', path)) config = root / 'resources' / 'config_template.toml' if config.is_file() and 'XXX' in config.read_text(): findings.append(Finding('PMQ205', Severity.TODO, 'config_template.toml still has the template title', 'describe the plugin settings and set a proper title', config, 1)) return findings
NOQA_RE = re.compile(r'#\s*noqa\s*:?\s*(PMQ\d+(?:\s*,\s*PMQ\d+)*)', re.IGNORECASE) def _honour_noqa(rule): """Drop the findings of a rule whose line carries a ``# noqa: PMQxxx`` comment (on purpose, like for flake8)""" @functools.wraps(rule) def wrapper(*args, **kwargs): lines = {} kept = [] for finding in rule(*args, **kwargs): if finding.path is not None and finding.line is not None: if finding.path not in lines: try: lines[finding.path] = Path(finding.path).read_text(errors='replace').splitlines() except OSError: lines[finding.path] = [] text = lines[finding.path] match = NOQA_RE.search(text[finding.line - 1]) if 0 < finding.line <= len(text) else None if match and finding.code in {c.strip().upper() for c in match.group(1).split(',')}: continue kept.append(finding) return kept return wrapper def _is_name(node: Optional[ast.AST], name: str) -> bool: if isinstance(node, ast.Call): node = node.func return isinstance(node, ast.Name) and node.id == name # ------------------------------------------------------------------------------------------------------------------- # PMQ3xx: plugin classes, from the source # ------------------------------------------------------------------------------------------------------------------- def _class_attrs(cls: ast.ClassDef) -> dict[str, ast.AST]: attrs = {} for node in cls.body: if isinstance(node, ast.Assign): for target in node.targets: if isinstance(target, ast.Name): attrs[target.id] = node.value elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name) and node.value is not None: attrs[node.target.id] = node.value return attrs def _literal(node: ast.AST): try: return ast.literal_eval(node) except (ValueError, SyntaxError, TypeError): return None def _base_names(cls: ast.ClassDef) -> list[str]: return [b.attr if isinstance(b, ast.Attribute) else getattr(b, 'id', '?') for b in cls.bases]
[docs] def check_plugin_source(path: Path, kind: str, class_name: str, static_fallback: bool = False) -> list[Finding]: """Rules on the source of a plugin module, without importing it Parameters ---------- path: Path the python file kind: str 'move' or the dimensionality of a viewer ('0D', '1D', '2D', 'ND') class_name: str name of the plugin class that is expected in the module static_fallback: bool if True also apply the rules that duplicate the checks done on the imported class (use it when the module cannot be imported because of a missing dependency) """ findings = [] try: tree = ast.parse(path.read_text(errors='replace'), filename=str(path)) except SyntaxError as e: return [syntax_error_finding('PMQ300', path, e)] for node in ast.walk(tree): if isinstance(node, (ast.Import, ast.ImportFrom)): module = node.module if isinstance(node, ast.ImportFrom) else ' '.join(a.name for a in node.names) if module and (module == 'pymodaq.daq_utils' or module.startswith('pymodaq.daq_utils.')): findings.append(Finding('PMQ307', Severity.ERROR, f"'{module}' does not exist anymore", 'import from pymodaq.utils (or pymodaq_utils / pymodaq_data / pymodaq_gui)', path, node.lineno)) cls = next((n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == class_name), None) if cls is None: # reported when loading the module, see plugin_testing return findings if ast.get_docstring(cls) is None: findings.append(Finding('PMQ310', Severity.WARNING, f'{class_name} has no docstring', 'document the instrument, the tested models and the needed drivers', path, cls.lineno)) if not any(isinstance(n, ast.If) and 'main' in ast.dump(n) and '__main__' in ast.dump(n) for n in tree.body): findings.append(Finding('PMQ308', Severity.WARNING, "no 'if __name__ == \"__main__\": main(__file__)' block", 'add it to be able to run and debug the plugin standalone', path)) attrs = _class_attrs(cls) methods = {n.name: n for n in cls.body if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))} if kind == 'move': findings.extend(_check_move_source(path, cls, attrs, static_fallback)) findings.extend(_check_deprecated_api(path, tree, methods)) mandatory = MANDATORY_MOVE_METHODS else: mandatory = MANDATORY_VIEWER_METHODS findings.extend(_check_legacy_settings(path, tree)) findings.extend(_check_controller_init(path, tree)) for problem in _platforms.invalid_values(*_platforms.declared_in_class(cls)): findings.append(Finding('PMQ319', Severity.WARNING, problem, 'supported_platforms: values of sys.platform (win32, linux, darwin); ' 'supported_architectures: values of platform.machine() (x86_64, aarch64, armv7l...)', path, cls.lineno)) simple_bases = all(b in ('DAQ_Move_base', 'DAQ_Viewer_base') for b in _base_names(cls)) if static_fallback and simple_bases: for meth in mandatory: # the base class still calls the old names (check_position, move_Abs...) if the method is not defined # (see PMQ312) old = legacy_names(kind).get(meth) if kind == 'move' else None if meth not in methods and not (old and old in methods): findings.append(Finding('PMQ306', Severity.ERROR, f'{class_name} should define {meth}()', 'implement it (see the template)', path, cls.lineno)) init = methods.get('ini_stage' if kind == 'move' else 'ini_detector') if init is not None: returns = [n for n in ast.walk(init) if isinstance(n, ast.Return)] if not returns or any(not isinstance(r.value, ast.Tuple) or len(r.value.elts) != 2 for r in returns): findings.append(Finding('PMQ309', Severity.WARNING, f'{init.name}() should return a tuple (info: str, initialized: bool)', 'return info, initialized', path, init.lineno)) return findings
def _check_move_source(path: Path, cls: ast.ClassDef, attrs: dict, static_fallback: bool) -> list[Finding]: findings = [] line = cls.lineno if '_epsilon' in attrs and '_epsilons' not in attrs: findings.append(Finding('PMQ302', Severity.WARNING, "'_epsilon' is deprecated", "use '_epsilons'", path, attrs['_epsilon'].lineno)) if 'stage_names' in attrs: findings.append(Finding('PMQ302', Severity.WARNING, "'stage_names' is deprecated", "use '_axis_names'", path, attrs['stage_names'].lineno)) if 'axis_names' in attrs and '_axis_names' not in attrs: findings.append(Finding('PMQ302', Severity.ERROR, "the axes are declared as 'axis_names', that is ignored", "use '_axis_names'", path, attrs['axis_names'].lineno)) dat = attrs.get('data_actuator_type') inherits = dat is None and not all(b == 'DAQ_Move_base' for b in _base_names(cls)) # may be set by a parent if not inherits and (dat is None or 'DataActuator' not in ast.dump(dat)): findings.append(Finding('PMQ303', Severity.WARNING, 'data_actuator_type is not DataActuatorType.DataActuator', 'new plugins should exchange DataActuator objects: set ' 'data_actuator_type = DataActuatorType.DataActuator', path, dat.lineno if dat is not None else line)) if static_fallback: axes = _literal(attrs['_axis_names']) if '_axis_names' in attrs else None units = _literal(attrs['_controller_units']) if '_controller_units' in attrs else None if isinstance(units, str): units = [units] elif isinstance(units, dict): if isinstance(axes, list): findings.append(Finding('PMQ304', Severity.ERROR, '_controller_units is a dict but _axis_names a list', 'define them with the same type', path, attrs['_controller_units'].lineno)) units = list(units.values()) for unit in units if isinstance(units, list) else []: if not is_valid_unit(unit): findings.append(Finding('PMQ305', Severity.ERROR, f"unit '{unit}' is unknown from pint", 'use a unit known from pint, for instance mm or degree', path, attrs['_controller_units'].lineno)) return findings def _check_deprecated_api(path: Path, tree: ast.Module, methods: dict) -> list[Finding]: """PMQ312-314: parts of the API of an actuator that were renamed or changed""" findings = [] for old, new in DEPRECATED_HOOKS.items(): if old in methods: findings.append(Finding('PMQ312', Severity.WARNING, f'{old}() is deprecated, it is still called but with ' f'a warning', f'rename it {new}()', path, methods[old].lineno)) for attr, new in DEPRECATED_ATTRIBUTES.items(): uses = [n for n in ast.walk(tree) if isinstance(n, ast.Attribute) and n.attr == attr and isinstance(n.value, ast.Name) and n.value.id == 'self'] if uses: more = f' ({len(uses)} times in this file)' if len(uses) > 1 else '' findings.append(Finding('PMQ313', Severity.WARNING, f"'{attr}' is deprecated{more}", f"use '{new}'", path, min(n.lineno for n in uses))) # comon_parameters of the actuators is a function since pymodaq 5 (it was a list), the one of the viewers a list names = {a.asname or a.name for n in ast.walk(tree) if isinstance(n, ast.ImportFrom) and (n.module or '').endswith('move_utility_classes') for a in n.names if a.name == 'comon_parameters'} if names: called = {id(n.func) for n in ast.walk(tree) if isinstance(n, ast.Call)} for node in ast.walk(tree): if isinstance(node, ast.Name) and node.id in names and isinstance(node.ctx, ast.Load) and \ id(node) not in called: findings.append(Finding('PMQ314', Severity.ERROR, "'comon_parameters' is used as a list but it is " "a function", "call it: comon_parameters_fun(is_multiaxes, axis_names=..., " "epsilon=...), or comon_parameters(epsilon)", path, node.lineno)) return findings def _check_legacy_settings(path: Path, tree: ast.Module) -> list[Finding]: """PMQ316: names of groups of settings that were renamed""" hits = [n for n in ast.walk(tree) if isinstance(n, ast.Constant) and n.value in LEGACY_SETTINGS] if not hits: return [] first = min(hits, key=lambda n: n.lineno) more = f' ({len(hits)} times in this file)' if len(hits) > 1 else '' return [Finding('PMQ316', Severity.WARNING, f"settings group '{first.value}' was renamed " f"'{LEGACY_SETTINGS[first.value]}'{more}", "PyMoDAQ still translates the old names in settings[...] (deprecated), but not in " "settings.child(...): use the new ones", path, first.lineno)] def _check_controller_init(path: Path, tree: ast.Module) -> list[Finding]: """PMQ318: deprecated call of ini_stage_init / ini_detector_init to initialize the controller""" calls = [n for n in ast.walk(tree) if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute) and n.func.attr in ('ini_stage_init', 'ini_detector_init') and isinstance(n.func.value, ast.Name) and n.func.value.id == 'self'] if not calls: return [] first = min(calls, key=lambda n: n.lineno) more = f' ({len(calls)} times in this file)' if len(calls) > 1 else '' return [Finding('PMQ318', Severity.WARNING, f'{first.func.attr}() is deprecated{more}', "write: if self.is_master: self.controller = AWrapper() else: self.controller = controller", path, first.lineno)] # ------------------------------------------------------------------------------------------------------------------- # PMQ33x: extensions (``extensions`` folder of a plugin package), from the source # ------------------------------------------------------------------------------------------------------------------- EXTENSIONS_DOC = 'https://pymodaq.cnrs.fr/en/latest/developer_folder/extension_plugins.html' def _module_strings(tree: ast.Module) -> dict[str, ast.Assign]: """The module level variables assigned a string, with their node""" found = {} for node in tree.body: if (isinstance(node, ast.Assign) and len(node.targets) == 1 and isinstance(node.targets[0], ast.Name) and isinstance(node.value, ast.Constant) and isinstance(node.value.value, str)): found[node.targets[0].id] = node return found def _root_bases(cls: ast.ClassDef, classes: dict[str, ast.ClassDef], seen: Optional[set] = None) -> set[str]: """Names of the bases of a class, following the bases defined in the same module""" seen = seen or set() names = set() for base in _base_names(cls): names.add(base) if base in classes and base not in seen: names |= _root_bases(classes[base], classes, seen | {cls.name}) return names
[docs] def check_extension_source(path: Path) -> list[Finding]: """Rules on a module of the ``extensions`` folder of a plugin package, without importing it * PMQ330: ``EXTENSION_NAME`` or ``CLASS_NAME`` missing in a module defining an extension: the Dashboard does not list it * PMQ331: ``CLASS_NAME`` is not the name of a class of the module * PMQ332: the class does not derive from ``CustomExt`` (``CustomApp`` has no access to the Dashboard) * PMQ333: import from a module of pymodaq that only remains as a deprecated shim * PMQ334: deprecated hooks ``setup_docks`` / ``setup_menu`` * PMQ335: the extension creates its own ExperimentManager or StateManager * PMQ336: the ``__init__`` does not take the ``parent`` and ``dashboard`` arguments given by the Dashboard * PMQ337: the ``__init__`` of a class deriving from ``CustomExt`` never calls ``self.setup_ui()`` * PMQ338: a class deriving directly from ``CustomExt`` has no ``_quit_fun`` guard, or one that never returns True * PMQ339: the extension class overrides ``quit_fun`` instead of ``_quit_fun`` """ try: tree = ast.parse(path.read_text(errors='replace'), filename=str(path)) except SyntaxError as e: return [syntax_error_finding('PMQ300', path, e)] findings = [] classes = {n.name: n for n in tree.body if isinstance(n, ast.ClassDef)} strings = _module_strings(tree) defines_ext = any('CustomExt' in _root_bases(c, classes) for c in classes.values()) for node in ast.walk(tree): if isinstance(node, (ast.Import, ast.ImportFrom)): modules = [node.module or ''] if isinstance(node, ast.ImportFrom) else [a.name for a in node.names] for module in modules: old = next((o for o in DEPRECATED_IMPORTS if module == o or module.startswith(o + '.')), None) if old is not None: findings.append(Finding('PMQ333', Severity.WARNING, f"'{module}' is deprecated", f"import from {DEPRECATED_IMPORTS[old]} instead", path, node.lineno)) if not (defines_ext or 'EXTENSION_NAME' in strings or 'CLASS_NAME' in strings): return findings # a helper module of the extensions folder, nothing else to check missing = [name for name in ('EXTENSION_NAME', 'CLASS_NAME') if name not in strings] if missing: findings.append(Finding('PMQ330', Severity.ERROR, f"{' and '.join(missing)} not defined: the Dashboard will not list this extension", "define them at the module level as strings: EXTENSION_NAME is the name displayed in " "the Dashboard, CLASS_NAME the name of the extension class", path, 1)) class_name = strings['CLASS_NAME'].value.value if 'CLASS_NAME' in strings else None imported = {a.asname or a.name.split('.')[0] for n in tree.body if isinstance(n, (ast.Import, ast.ImportFrom)) for a in n.names} cls = classes.get(class_name) if class_name else None if class_name and cls is None and class_name not in imported: findings.append(Finding('PMQ331', Severity.ERROR, f"CLASS_NAME = '{class_name}' but there is no such class " f"in the module", 'CLASS_NAME should be the name of the extension class, the Dashboard ' 'gets the class from it', path, strings['CLASS_NAME'].lineno)) if cls is not None: bases = _root_bases(cls, classes) if 'CustomExt' not in bases and ('CustomApp' in bases or not bases - {'object'}): findings.append(Finding('PMQ332', Severity.WARNING, f'{cls.name} does not derive from CustomExt', "an extension is a CustomExt (from pymodaq.utils.custom_ext) to get the " "Dashboard, not a CustomApp", path, cls.lineno)) methods = {n.name: n for n in cls.body if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))} for old, new in DEPRECATED_EXTENSION_HOOKS.items(): if old in methods: findings.append(Finding('PMQ334', Severity.WARNING, f'{old}() is deprecated', f"rename it {new}()", path, methods[old].lineno)) init = methods.get('__init__') if init is not None: args = init.args if len(args.posonlyargs) + len(args.args) < 3 and args.vararg is None: findings.append(Finding('PMQ336', Severity.ERROR, '__init__ should take (self, parent, dashboard)', 'the Dashboard creates the extension with the dock area and itself: ' 'def __init__(self, parent, dashboard) then super().__init__(parent, dashboard)', path, init.lineno)) if 'CustomExt' in _base_names(cls) and not any( isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute) and n.func.attr == 'setup_ui' for n in ast.walk(init)): findings.append(Finding('PMQ337', Severity.WARNING, '__init__ never calls self.setup_ui()', 'without it no dock, menu or toolbar is created: call self.setup_ui() at the ' 'end of __init__', path, init.lineno)) if 'CustomExt' in _root_bases(cls, classes) and 'quit_fun' in methods: findings.append(Finding('PMQ339', Severity.ERROR, f'{cls.name} overrides quit_fun()', 'quit_fun of CustomExt calls _quit_fun (the guard) then closes the window and ' 'shows the Dashboard again: override _quit_fun instead, so that these are kept', path, methods['quit_fun'].lineno)) if 'CustomExt' in _base_names(cls) and 'quit_fun' not in methods: quit_fun = methods.get('_quit_fun') if quit_fun is None: findings.append(Finding('PMQ338', Severity.WARNING, f'{cls.name} does not define _quit_fun()', 'quit_fun only closes the extension when _quit_fun() returns True, and the ' 'one of CustomExt returns None: define _quit_fun as the guard that checks ' 'that nothing is left running (threads, workers...), return False if ' 'something is, True otherwise', path, cls.lineno)) elif not any(isinstance(n, ast.Return) and n.value is not None and not (isinstance(n.value, ast.Constant) and not n.value.value) for n in ast.walk(quit_fun)): findings.append(Finding('PMQ338', Severity.WARNING, '_quit_fun() never returns True', 'quit_fun only closes the extension when _quit_fun() returns True: it is the ' 'guard that returns False while something is left running (threads, workers' '...) and True when everything is released', path, quit_fun.lineno)) in_main = {n for f in tree.body if isinstance(f, ast.FunctionDef) and f.name == 'main' for n in ast.walk(f)} for node in ast.walk(tree): if (isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id in DASHBOARD_OWNED and node not in in_main): findings.append(Finding('PMQ335', Severity.WARNING, f'{node.func.id}() created by the extension', 'use the ones of the Dashboard: self.experiment_manager and self.state_manager', path, node.lineno)) return findings
[docs] def check_extensions(root: Path) -> list[Finding]: """The rules on every module of the ``extensions`` folder of the package""" folder = root / 'extensions' findings = [] if folder.is_dir(): for path in _python_files(folder): if not path.name.startswith('_'): findings.extend(check_extension_source(path)) return findings
[docs] def check_package_sources(package: str) -> list[Finding]: """All the static rules on an installed (or editable) plugin package: packaging, leftovers and plugin classes""" from pymodaq_utils.plugin_tools.checks import find_plugin_modules # no import cycle at module level root = package_root(package) if root is None: return [Finding('PMQ100', Severity.ERROR, f'package {package} not found', 'install the plugin')] project = project_root(package) findings = check_pyproject(package, project) if project is not None else [] findings.extend(check_file_names(root)) findings.extend(check_leftovers(package, root, project)) findings.extend(check_python_code(root)) findings.extend(check_extensions(root)) for mod in find_plugin_modules(package): file = Path(importlib.util.find_spec(mod.import_path).origin) findings.extend(check_plugin_source(file, mod.kind, mod.class_name)) return findings
[docs] def check_os_classifiers(package_folder: Path, project: Path) -> list[Finding]: """PMQ320: the ``Operating System`` classifiers of the pyproject.toml do not match the ``supported_platforms`` declared by the instrument plugins""" path = project / 'pyproject.toml' try: proj = toml.load(path).get('project', {}) except toml.TomlDecodeError: return [] if 'classifiers' in proj.get('dynamic', []): return [] declared = _platforms.package_platforms(package_folder) expected = set(_platforms.classifiers_for(declared)) actual = set(_platforms.pyproject_os_classifiers(proj.get('classifiers', []))) if actual == expected: return [] what = (f"the plugins work on {_platforms.describe(declared)}" if declared else 'no instrument plugin declares supported_platforms (they work on all the systems)') line = next((i for i, text in enumerate(path.read_text().splitlines(), 1) if 'classifiers' in text), None) return [Finding('PMQ320', Severity.WARNING, f'the Operating System classifiers {sorted(actual) or "(none)"} do not match the code: {what}', 'run check_plugin --fix-classifiers to write ' + ', '.join(sorted(expected)), path, line)]
check_plugin_source = _honour_noqa(check_plugin_source) check_python_code = _honour_noqa(check_python_code) check_leftovers = _honour_noqa(check_leftovers) check_extension_source = _honour_noqa(check_extension_source)