"""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
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)