diff --git a/NEWS.md b/NEWS.md index 7b5fa25b..353814b5 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,10 @@ +## Unreleased + +- TOML-backed labconfig and app-config writing now depends on `tomli-w`. The package + dependency is declared in `labscript-utils`, but existing editable or conda + environments may need that dependency installed explicitly when upgrading. + + ## [2.15.0] - 2019-12-04 This release includes one bugfix, one enhancement, one update for compatibility with a @@ -60,4 +67,4 @@ compatibility with a newer version of a library. ([PR #91](https://bitbucket.org/labscript_suite/labscript_utils/pull-requests/91)) - Compatibility with `importlib_metadata` >= 0.21. Contributed by Chris Billington - ([PR #92](https://bitbucket.org/labscript_suite/labscript_utils/pull-requests/88)) \ No newline at end of file + ([PR #92](https://bitbucket.org/labscript_suite/labscript_utils/pull-requests/88)) diff --git a/README.md b/README.md index eb133843..f63c5b26 100644 --- a/README.md +++ b/README.md @@ -19,3 +19,9 @@ Shared modules used by the [*labscript suite*](https://github.com/labscript-suit ## Installation labscript-utils is distributed as a Python package on [PyPI](https://pypi.org/user/labscript-suite) and [Anaconda Cloud](https://anaconda.org/labscript-suite), and should be installed with other components of the _labscript suite_. Please see the [installation guide](https://docs.labscriptsuite.org/en/latest/installation) for details. + +TOML-backed configuration support depends on `tomli-w` at runtime. This dependency is +declared in `labscript-utils` package metadata, so fresh installs should receive it +automatically. Existing editable or conda environments created before this dependency +was added may need `conda install tomli-w` or an updated `labscript-utils` +installation. diff --git a/docs/source/labconfig.rst b/docs/source/labconfig.rst index fa727640..f342a17b 100644 --- a/docs/source/labconfig.rst +++ b/docs/source/labconfig.rst @@ -1,19 +1,19 @@ -The *labconfig.ini* file +The *labconfig.toml* file ======================== -The `labconfig.ini` file is a global configuration file for your **labscript-suite** installation. +The `labconfig.toml` file is a global configuration file for your **labscript-suite** installation. It contains configurable settings that govern how the individual components of the suite operate. The name of this file must be the host computer's system name. -So if my system's name was `heisenberg`, the labconfig file name would be `heisenberg.ini`. +So if my system's name was `heisenberg`, the labconfig file name would be `heisenberg.toml`. This file should be located in the `labscript-suite` directory in the user space, in the `labconfig` subdirectory. When :doc:`installing the **labscript-suite** for the first time `, running the `labscript-profile-create` command will automatically generate the `labscript-suite` user space directory in the correct place -and generate a `labconfig.ini` file for use on your system. -By editing the `ini` file named after your system, you can update the configuration settings of your **labscript-suite** installation. +and generate a `labconfig.toml` file for use on your system. +By editing the TOML file named after your system, you can update the configuration settings of your **labscript-suite** installation. -The Default *labconfig.ini* ---------------------------- +The Default *labconfig.toml* +---------------------------- Below is a copy of the default lab configuration if you were to install the **labscript-suite** today. @@ -23,5 +23,5 @@ Below is a copy of the default lab configuration if you were to install the **la Instead, if keys are missing from your local profile, default behavior will be assumed. To implement the added functionality, you will need to manually add/change the keys in your local labconfig. -.. include:: ../../labscript_profile/default_profile/labconfig/example.ini +.. include:: ../../labscript_profile/default_profile/labconfig/example.toml :code: diff --git a/labscript_profile/__init__.py b/labscript_profile/__init__.py index 55571f78..f2b12580 100644 --- a/labscript_profile/__init__.py +++ b/labscript_profile/__init__.py @@ -1,12 +1,14 @@ import site import sys import os -from configparser import ConfigParser, NoSectionError, NoOptionError +import configparser +from ast import literal_eval from pathlib import Path from subprocess import check_output import socket from getpass import getuser +from .toml_config import TomlConfigParser, dump_toml_file, load_toml_file # The contents of this file are imported every time the Python interpreter starts up, # owing to our custom .pth file that runs the below two functions. This ensures that @@ -36,6 +38,13 @@ def hostname(): def default_labconfig_path(): + if LABSCRIPT_SUITE_PROFILE is None: + return None + return LABSCRIPT_SUITE_PROFILE / 'labconfig' / f'{hostname()}.toml' + +# LEGACY INI COMPATIBILITY. DEPRECATED CODE, WILL BE REMOVED. +def legacy_labconfig_path(): + """Temporary legacy INI labconfig location. Slated for removal soon.""" if LABSCRIPT_SUITE_PROFILE is None: return None return LABSCRIPT_SUITE_PROFILE / 'labconfig' / f'{hostname()}.ini' @@ -47,17 +56,113 @@ def add_userlib_and_pythonlib(): re-implements finding and reading the config file so as to not import labscript_utils, since we dont' want to import something like labscript_utils every time the interpreter starts up""" - labconfig = default_labconfig_path() - if labconfig is not None and labconfig.exists(): - # str() below is for py36 compat, where ConfigParser can't deal with Path objs - config = ConfigParser( - defaults={'labscript_suite': str(LABSCRIPT_SUITE_PROFILE)} - ) - config.read(labconfig) - for option in ['userlib', 'pythonlib']: - try: - paths = config.get('DEFAULT', option).split(',') - except (NoSectionError, NoOptionError): - paths = [] - for path in paths: + labconfig = ensure_labconfig() + if labconfig is None: + return + if not labconfig.exists(): + return + config = TomlConfigParser(defaults={'labscript_suite': str(LABSCRIPT_SUITE_PROFILE)}) + config.read_toml(labconfig) + for option in ['userlib', 'pythonlib']: + try: + paths = config.get('default', option) + except (configparser.NoSectionError, configparser.NoOptionError): + paths = '' + if paths: + for path in paths.split(','): site.addsitedir(path) + + +def ensure_labconfig(): + """Return the TOML labconfig path, converting a legacy INI file if needed. + + The legacy conversion path is temporary and slated for removal soon. + """ + labconfig = default_labconfig_path() + if labconfig is None: + return None + if labconfig.exists(): + return labconfig + # LEGACY INI COMPATIBILITY. DEPRECATED CODE, WILL BE REMOVED. + legacy = legacy_labconfig_path() + if legacy is None or not legacy.exists(): + return labconfig + _convert_legacy_labconfig(legacy, labconfig) + return labconfig + + +def _example_labconfig_data(): + if LABSCRIPT_SUITE_PROFILE is not None: + profile_example = LABSCRIPT_SUITE_PROFILE / 'labconfig' / 'example.toml' + if profile_example.exists(): + return load_toml_file(profile_example) + bundled_example = Path(__file__).resolve().parent / 'default_profile' / 'labconfig' / 'example.toml' + if bundled_example.exists(): + return load_toml_file(bundled_example) + return {} + +# LEGACY INI COMPATIBILITY. DEPRECATED CODE, WILL BE REMOVED. +def _coerce_legacy_value(value, template_value): + if template_value is None: + return value + if isinstance(template_value, bool): + if isinstance(value, bool): + return value + if isinstance(value, str): + lowered = value.strip().lower() + if lowered in configparser.ConfigParser.BOOLEAN_STATES: + return configparser.ConfigParser.BOOLEAN_STATES[lowered] + return value + if isinstance(template_value, int): + try: + return int(value) + except (TypeError, ValueError): + return value + if isinstance(template_value, float): + try: + return float(value) + except (TypeError, ValueError): + return value + if isinstance(template_value, list): + if not isinstance(value, str): + return value + try: + parsed = literal_eval(value) + except (SyntaxError, ValueError): + return value + if not isinstance(parsed, (list, tuple)): + return value + if not template_value: + return list(parsed) + template_item = template_value[0] + return [_coerce_legacy_value(item, template_item) for item in parsed] + return value + +# LEGACY INI COMPATIBILITY. DEPRECATED CODE, WILL BE REMOVED. +def _convert_legacy_labconfig(legacy_path, toml_path): + """Temporary legacy migration path from INI to TOML. Slated for removal soon.""" + config = configparser.ConfigParser(interpolation=None) + config.read(legacy_path) + template_data = _example_labconfig_data() + default_template = template_data.get('default', {}) + data = { + 'default': { + key: _coerce_legacy_value(value, default_template.get(key)) + for key, value in config.defaults().items() + } + } + for section in config.sections(): + canonical_section = section.lower() + section_items = dict(config._sections[section]) + section_items.pop('__name__', None) + autoload = section_items.get('autoload_config_file') + # LEGACY INI COMPATIBILITY. DEPRECATED CODE, WILL BE REMOVED. + if isinstance(autoload, str) and autoload.lower().endswith('.ini'): + section_items['autoload_config_file'] = autoload[:-4] + '.toml' + section_template = template_data.get(canonical_section, {}) + data[canonical_section] = { + key: _coerce_legacy_value(value, section_template.get(key)) + for key, value in section_items.items() + } + toml_path.parent.mkdir(parents=True, exist_ok=True) + dump_toml_file(toml_path, data) diff --git a/labscript_profile/create.py b/labscript_profile/create.py index 38a67734..dcea2d11 100644 --- a/labscript_profile/create.py +++ b/labscript_profile/create.py @@ -1,16 +1,31 @@ import sys import os import shutil -import configparser from pathlib import Path from subprocess import check_output -from labscript_profile import LABSCRIPT_SUITE_PROFILE, default_labconfig_path +import h5py +from labscript_profile import ( + LABSCRIPT_SUITE_PROFILE, + default_labconfig_path, + legacy_labconfig_path, +) import argparse +from labscript_profile.toml_config import dump_toml_file, load_toml_file _here = os.path.dirname(os.path.abspath(__file__)) DEFAULT_PROFILE_CONTENTS = os.path.join(_here, 'default_profile') +def _replace_backslashes(value): + if isinstance(value, dict): + return {key: _replace_backslashes(child) for key, child in value.items()} + if isinstance(value, list): + return [_replace_backslashes(child) for child in value] + if isinstance(value, str): + return value.replace('\\', os.path.sep) + return value + + def make_shared_secret(directory): """Create a new zprocess shared secret file in the given directory and return its filepath""" @@ -31,37 +46,34 @@ def make_labconfig_file(apparatus_name = None): Overrides the default apparatus name with the provided one if not None """ - source_path = os.path.join(LABSCRIPT_SUITE_PROFILE, 'labconfig', 'example.ini') + source_path = os.path.join(LABSCRIPT_SUITE_PROFILE, 'labconfig', 'example.toml') target_path = default_labconfig_path() - if os.path.exists(target_path): + # LEGACY INI COMPATIBILITY. DEPRECATED CODE, WILL BE REMOVED. + legacy_path = legacy_labconfig_path() + if os.path.exists(target_path) or (legacy_path is not None and os.path.exists(legacy_path)): raise FileExistsError(target_path) - with open(source_path) as infile, open(target_path, 'w') as outfile: - data = infile.read() - data = data.replace('\\', os.path.sep) - outfile.write(data) - - # Now change some things about it: - config = configparser.ConfigParser(interpolation=None) - config.read(target_path) + config = load_toml_file(source_path) + if os.path.sep != '\\': + config = _replace_backslashes(config) if sys.platform == 'linux': - config.set('programs', 'text_editor', 'gedit') + config['programs']['text_editor'] = 'gedit' elif sys.platform == 'darwin': - config.set('programs', 'text_editor', 'open') - config.set('programs', 'text_editor_arguments', '-a TextEdit {file}') + config['programs']['text_editor'] = 'open' + config['programs']['text_editor_arguments'] = '-a TextEdit {file}' if sys.platform != 'win32': - config.set('programs', 'hdf5_viewer', 'hdfview') - config.set('DEFAULT', 'shared_drive', '$HOME/labscript_shared') + config['programs']['hdf5_viewer'] = 'hdfview' + config['default']['shared_drive'] = '$HOME/labscript_shared' shared_secret = make_shared_secret(target_path.parent) shared_secret_entry = Path( '%(labscript_suite)s', shared_secret.relative_to(LABSCRIPT_SUITE_PROFILE) ) - config.set('security', 'shared_secret', str(shared_secret_entry)) + config['security']['shared_secret'] = str(shared_secret_entry) if apparatus_name is not None: print(f'\tSetting apparatus name to \'{apparatus_name}\'') - config.set('DEFAULT', 'apparatus_name', apparatus_name) + config['default']['apparatus_name'] = apparatus_name - with open(target_path, 'w') as f: - config.write(f) + target_path.parent.mkdir(parents=True, exist_ok=True) + dump_toml_file(target_path, config) def compile_connection_table(): """Compile the connection table defined in the labconfig file @@ -75,8 +87,9 @@ def compile_connection_table(): # if runmanager doesn't import, skip compilation return - config = configparser.ConfigParser(defaults = {'labscript_suite': str(LABSCRIPT_SUITE_PROFILE)}) - config.read(default_labconfig_path()) + from labscript_utils.labconfig import LabConfig + + config = LabConfig() # The path to the user's connection_table.py script script_path = os.path.expandvars(config['paths']['connection_table_py']) @@ -84,8 +97,9 @@ def compile_connection_table(): output_h5_path = os.path.expandvars(config['paths']['connection_table_h5']) # create output directory, if needed Path(output_h5_path).parent.mkdir(parents=True, exist_ok=True) - # compile the h5 file - runmanager.new_globals_file(output_h5_path) + # Create a fresh HDF5 target for the compile output. + with h5py.File(output_h5_path, 'w'): + pass def dummy_callback(success): pass @@ -164,4 +178,4 @@ def create_profile(apparatus_name = None, compile_table = False): if compile_table: print('Compiling the example connection table') - compile_connection_table() \ No newline at end of file + compile_connection_table() diff --git a/labscript_profile/default_profile/labconfig/example.ini b/labscript_profile/default_profile/labconfig/example.ini deleted file mode 100644 index 7f219dc1..00000000 --- a/labscript_profile/default_profile/labconfig/example.ini +++ /dev/null @@ -1,64 +0,0 @@ -[DEFAULT] -apparatus_name = example_apparatus -shared_drive = C: -experiment_shot_storage = %(shared_drive)s\Experiments\%(apparatus_name)s -userlib= %(labscript_suite)s\userlib -pythonlib = %(userlib)s\pythonlib -labscriptlib = %(userlib)s\labscriptlib\%(apparatus_name)s -analysislib = %(userlib)s\analysislib\%(apparatus_name)s -app_saved_configs = %(labscript_suite)s\app_saved_configs\%(apparatus_name)s -user_devices = user_devices - -[paths] -connection_table_h5 = %(experiment_shot_storage)s\connection_table.h5 -connection_table_py = %(labscriptlib)s\connection_table.py - -[servers] -zlock = localhost -runmanager = localhost - -[ports] -BLACS = 42517 -lyse = 42519 -runviewer = 42521 -runmanager = 42523 -zlock = 7339 -zlog = 7340 -zprocess_remote = 7341 - -[timeouts] -communication_timeout = 60 - -[programs] -text_editor = %%PROGRAMFILES%%\Sublime Text 3\sublime_text.exe -text_editor_arguments = {file} -hdf5_viewer = %%LOCALAPPDATA%%\HDF_Group\HDFView\3.1.0\hdfview.bat -hdf5_viewer_arguments = {file} - -[labscript] -save_hg_info = False -save_git_info = False - -[BLACS/plugins] -connection_table = True -connection_table.hashable_types = ['.py', '.txt', '.ini', '.json'] -connection_table.polling_interval = 1.0 - -delete_repeated_shots = False -general = True -memory = False -progress_bar = False -theme = True - - -[lyse] -autoload_config_file = %(app_saved_configs)s\lyse\lyse.ini -integer_indexing = False - -[runmanager] -autoload_config_file = %(app_saved_configs)s\runmanager\runmanager.ini -output_folder_format = %%Y\%%m\%%d\{sequence_index:04d} -filename_prefix_format = %%Y-%%m-%%d_{sequence_index:04d}_{script_basename} - -[security] -shared_secret = %(labscript_suite)s\labconfig\zpsecret-b810f83f.key diff --git a/labscript_profile/default_profile/labconfig/example.toml b/labscript_profile/default_profile/labconfig/example.toml new file mode 100644 index 00000000..f655e501 --- /dev/null +++ b/labscript_profile/default_profile/labconfig/example.toml @@ -0,0 +1,64 @@ +[default] +apparatus_name = 'example_apparatus' +shared_drive = 'C:' +experiment_shot_storage = '%(shared_drive)s\Experiments\%(apparatus_name)s' +userlib = '%(labscript_suite)s\userlib' +pythonlib = '%(userlib)s\pythonlib' +labscriptlib = '%(userlib)s\labscriptlib\%(apparatus_name)s' +analysislib = '%(userlib)s\analysislib\%(apparatus_name)s' +app_saved_configs = '%(labscript_suite)s\app_saved_configs\%(apparatus_name)s' +user_devices = 'user_devices' + +[paths] +connection_table_h5 = '%(experiment_shot_storage)s\connection_table.h5' +connection_table_py = '%(labscriptlib)s\connection_table.py' + +[servers] +zlock = 'localhost' +runmanager = 'localhost' +blacs = 'localhost' +lyse = 'localhost' + +[ports] +blacs = 42517 +lyse = 42519 +runviewer = 42521 +runmanager = 42523 +zlock = 7339 +zlog = 7340 +zprocess_remote = 7341 + +[timeouts] +communication_timeout = 60 + +[programs] +text_editor = '%%PROGRAMFILES%%\Sublime Text 3\sublime_text.exe' +text_editor_arguments = '{file}' +hdf5_viewer = '%%LOCALAPPDATA%%\HDF_Group\HDFView\3.1.0\hdfview.bat' +hdf5_viewer_arguments = '{file}' + +[labscript] +save_hg_info = false +save_git_info = false + +["blacs/plugins"] +connection_table = true +"connection_table.hashable_types" = ['.py', '.txt', '.ini', '.toml', '.json'] +"connection_table.polling_interval" = 1.0 +delete_repeated_shots = false +general = true +memory = false +progress_bar = false +theme = true + +[lyse] +autoload_config_file = '%(app_saved_configs)s\lyse\lyse.toml' +integer_indexing = false + +[runmanager] +autoload_config_file = '%(app_saved_configs)s\runmanager\runmanager.toml' +output_folder_format = '%%Y\%%m\%%d\{sequence_index:04d}' +filename_prefix_format = '%%Y-%%m-%%d_{sequence_index:04d}_{script_basename}' + +[security] +shared_secret = '%(labscript_suite)s\labconfig\zpsecret-b810f83f.key' diff --git a/labscript_profile/toml_config.py b/labscript_profile/toml_config.py new file mode 100644 index 00000000..e5c6d756 --- /dev/null +++ b/labscript_profile/toml_config.py @@ -0,0 +1,153 @@ +import configparser + +try: + import tomllib +except ImportError: + import tomli as tomllib + +import tomli_w + + +def load_toml_file(filename): + with open(filename, 'rb') as f: + return tomllib.load(f) + + +def dump_toml_file(filename, data): + with open(filename, 'wb') as f: + tomli_w.dump(data, f) + + +class TomlBasicInterpolation(configparser.BasicInterpolation): + """Basic interpolation that leaves non-string TOML values alone.""" + + def before_get(self, parser, section, option, value, defaults): + if not isinstance(value, str): + return value + return super().before_get(parser, section, option, value, defaults) + + +class TomlConfigParser(configparser.ConfigParser): + """ConfigParser variant that preserves native TOML values.""" + + def __init__(self, *args, **kwargs): + self.lowercase_keys_and_sections = kwargs.pop('lowercase_keys_and_sections', True) + if self.lowercase_keys_and_sections: + kwargs.setdefault('default_section', 'default') + kwargs.setdefault('interpolation', TomlBasicInterpolation()) + super().__init__(*args, **kwargs) + if not self.lowercase_keys_and_sections: + self.optionxform = str + + def _canonical_section_name(self, section): + if section is None: + return None + if not self.lowercase_keys_and_sections: + return section + return str(section).lower() + + def _normalize_loaded_sections(self): + if not self.lowercase_keys_and_sections: + return + renamed_sections = {} + migrated_defaults = {} + for name, section_data in self._sections.items(): + canonical = self._canonical_section_name(name) + if canonical == self.default_section: + migrated_defaults.update(section_data) + continue + existing = renamed_sections.get(canonical) + if existing is not None and existing is not section_data: + raise configparser.DuplicateSectionError(canonical) + renamed_sections[canonical] = section_data + self._defaults.update(migrated_defaults) + self._sections = renamed_sections + self._proxies = {self.default_section: configparser.SectionProxy(self, self.default_section)} + for canonical in renamed_sections: + self._proxies[canonical] = configparser.SectionProxy(self, canonical) + + def add_section(self, section): + return super().add_section(self._canonical_section_name(section)) + + def has_section(self, section): + return super().has_section(self._canonical_section_name(section)) + + def options(self, section): + return super().options(self._canonical_section_name(section)) + + def has_option(self, section, option): + return super().has_option(self._canonical_section_name(section), option) + + def remove_section(self, section): + return super().remove_section(self._canonical_section_name(section)) + + def remove_option(self, section, option): + return super().remove_option(self._canonical_section_name(section), option) + + def __getitem__(self, key): + return super().__getitem__(self._canonical_section_name(key)) + + def set(self, section, option, value=None): + section = self._canonical_section_name(section) + if not section or section == self.default_section: + sectdict = self._defaults + else: + try: + sectdict = self._sections[section] + except KeyError: + raise configparser.NoSectionError(section) from None + sectdict[self.optionxform(option)] = value + + def getboolean( + self, section, option, *, raw=False, vars=None, fallback=configparser._UNSET + ): + value = self.get(section, option, raw=raw, vars=vars, fallback=fallback) + if value is fallback: + return value + if isinstance(value, bool): + return value + return self._convert_to_boolean(value) + + def get(self, section, option, *, raw=False, vars=None, fallback=configparser._UNSET): + return super().get( + self._canonical_section_name(section), + option, + raw=raw, + vars=vars, + fallback=fallback, + ) + + def items(self, section=configparser._UNSET, raw=False, vars=None): + if section is configparser._UNSET: + return super().items(section=section, raw=raw, vars=vars) + return super().items(self._canonical_section_name(section), raw=raw, vars=vars) + + def read(self, filenames, encoding=None): + read_ok = super().read(filenames, encoding=encoding) + self._normalize_loaded_sections() + return read_ok + + def read_toml(self, filename): + data = load_toml_file(filename) + if not isinstance(data, dict): + raise TypeError("Top-level TOML document must be a mapping of sections") + for section_name, section in data.items(): + if not isinstance(section, dict): + raise TypeError(f"Section {section_name!r} must contain key/value pairs") + section_name = self._canonical_section_name(section_name) + if section_name != self.default_section and not self.has_section(section_name): + self.add_section(section_name) + for option, value in section.items(): + self.set(section_name, str(option), value) + return [str(filename)] + + def to_toml_dict(self): + data = {} + if self.defaults(): + data[self.default_section] = dict(self.defaults()) + for section in self.sections(): + data[section] = dict(self._sections[section]) + return data + + def write_toml(self, filename): + dump_toml_file(filename, self.to_toml_dict()) diff --git a/labscript_utils/device_registry/_device_registry.py b/labscript_utils/device_registry/_device_registry.py index e5a122a8..0e2348a0 100644 --- a/labscript_utils/device_registry/_device_registry.py +++ b/labscript_utils/device_registry/_device_registry.py @@ -74,7 +74,7 @@ def _get_device_dirs(): """Return the directory of labscript_devices, and the folders containing submodules of any packages listed in the user_devices labconfig setting""" try: - user_devices = LabConfig().get('DEFAULT', 'user_devices') + user_devices = LabConfig().get('default', 'user_devices') except (LabConfig.NoOptionError, LabConfig.NoSectionError): user_devices = 'user_devices' # Split on commas, remove whitespace: diff --git a/labscript_utils/excepthook/__init__.py b/labscript_utils/excepthook/__init__.py index 9ac5975d..c6551fc8 100644 --- a/labscript_utils/excepthook/__init__.py +++ b/labscript_utils/excepthook/__init__.py @@ -28,6 +28,7 @@ class l: logger = None child_processes = [] +_original_showwarning = warnings.showwarning def install_thread_excepthook(): @@ -83,12 +84,11 @@ def tkhandler(exceptclass, exception, exec_info, reraise=True): def logwarning(message, category, filename, lineno, file=None, line=None): logmessage = warnings.formatwarning(message, category, filename, lineno, line) l.logger.warn(logmessage) - warnings._showwarning(message, category, filename, lineno, file, line) + _original_showwarning(message, category, filename, lineno, file, line) def set_logger(logger): l.logger = logger - warnings._showwarning = warnings.showwarning warnings.showwarning = logwarning # Check for tkinter availability. Tkinter is frustratingly not available diff --git a/labscript_utils/file_utils.py b/labscript_utils/file_utils.py new file mode 100644 index 00000000..ee032183 --- /dev/null +++ b/labscript_utils/file_utils.py @@ -0,0 +1,45 @@ +##################################################################### +# # +# file_utils.py # +# # +# Copyright 2013, Monash University # +# # +# This file is part of the labscript suite (see # +# http://labscriptsuite.org) and is licensed under the Simplified # +# BSD License. See the license.txt file in the root of the project # +# for the full license. # +# # +##################################################################### + +import os +import re + + +def next_available_indexed_filepath(filepath, suffix_format, start=None): + """Return the first non-existent filepath created by appending a formatted suffix. + + ``suffix_format`` is formatted with a single ``index`` keyword argument and is + inserted between the filepath stem and its extension. If filepath already ends + with a suffix matching the pattern, that suffix is stripped. If ``start`` is + ``None``, the search begins at the next higher index from that suffix. Otherwise + ``start`` is used exactly as provided.""" + basename, ext = os.path.splitext(filepath) + suffix_pattern = re.escape(suffix_format) + suffix_pattern = re.sub( + r'\\\{index(?::[^}]*)?\\\}', + lambda match: r'(?P\d+)', + suffix_pattern, + ) + match = re.search(suffix_pattern + r'$', basename) + if match is not None: + basename = basename[:match.start()] + if start is None: + start = int(match.group('index')) + 1 + if start is None: + start = 0 + index = start + while True: + candidate = basename + suffix_format.format(index=index) + ext + if not os.path.exists(candidate): + return candidate, index + index += 1 diff --git a/labscript_utils/filewatcher.py b/labscript_utils/filewatcher.py index 4b6f84b2..edc081a8 100644 --- a/labscript_utils/filewatcher.py +++ b/labscript_utils/filewatcher.py @@ -268,7 +268,7 @@ def callback(name, modified, event=None): callback, files=test_files[0], folders=test_folder, - hashable_types=['.py', '.ini', '.txt'], + hashable_types=['.py', '.toml', '.txt'], interval=2, ) diff --git a/labscript_utils/labconfig.py b/labscript_utils/labconfig.py index d110fe82..398ac744 100644 --- a/labscript_utils/labconfig.py +++ b/labscript_utils/labconfig.py @@ -10,31 +10,109 @@ # for the full license. # # # ##################################################################### -import os import configparser +import os +import warnings from ast import literal_eval -from pprint import pformat from pathlib import Path -import warnings from labscript_utils import dedent -from labscript_profile import default_labconfig_path, LABSCRIPT_SUITE_PROFILE +from labscript_profile import ( + LABSCRIPT_SUITE_PROFILE, + default_labconfig_path, + ensure_labconfig, +) +from labscript_profile.toml_config import ( + TomlBasicInterpolation, + TomlConfigParser, + dump_toml_file, + load_toml_file, +) + default_config_path = default_labconfig_path() -class EnvInterpolation(configparser.BasicInterpolation): - """Interpolation which expands environment variables in values, - by post-filtering BasicInterpolation.before_get()""" +def format_path_for_display(path): + """Return an absolute path with the user's home abbreviated for display.""" + absolute_path = os.path.abspath(os.path.expanduser(os.fspath(path))) + home_path = str(Path.home()) + normalized_path = os.path.normcase(os.path.normpath(absolute_path)) + normalized_home = os.path.normcase(os.path.normpath(home_path)) + display_home = '%USERPROFILE%' if os.name == 'nt' else '~' + if normalized_path == normalized_home: + return display_home + home_prefix = normalized_home + os.path.sep + if normalized_path.startswith(home_prefix): + relative_path = os.path.relpath(absolute_path, home_path) + separator = '\\' if os.name == 'nt' else '/' + return display_home + separator + relative_path.replace(os.path.sep, separator) + return absolute_path + + +def get_default_appconfig_file( + exp_config, app_name, config_filename, ensure_directory=False +): + try: + default_path = os.path.join(exp_config.get('default', 'app_saved_configs'), app_name) + except (LabConfig.NoOptionError, LabConfig.NoSectionError): + exp_config.set( + 'default', + 'app_saved_configs', + os.path.join( + '%(labscript_suite)s', + 'userlib', + 'app_saved_configs', + '%(apparatus_name)s', + ), + ) + default_path = os.path.join(exp_config.get('default', 'app_saved_configs'), app_name) + if ensure_directory: + os.makedirs(default_path, exist_ok=True) + return os.path.join(default_path, config_filename) + + +class LabscriptApplication(object): + app_name = None + default_config_filename = None + + def init_config_window_title(self): + self.base_window_title = self.ui.windowTitle().split(' - ', 1)[0] + + def get_default_config_file(self, ensure_directory=False): + if self.app_name is None or self.default_config_filename is None: + raise NotImplementedError( + 'LabscriptApplication requires app_name and default_config_filename' + ) + return get_default_appconfig_file( + self.exp_config, + self.app_name, + self.default_config_filename, + ensure_directory=ensure_directory, + ) + + def set_config_window_title(self, filename): + self.ui.setWindowTitle( + f'{self.base_window_title} - {format_path_for_display(filename)}' + ) + + +class EnvInterpolation(TomlBasicInterpolation): + """Interpolation that expands environment variables after TOML/basic interpolation.""" def before_get(self, *args): value = super(EnvInterpolation, self).before_get(*args) - return os.path.expandvars(value) + if isinstance(value, str): + return os.path.expandvars(value) + return value -class LabConfig(configparser.ConfigParser): +class LabConfig(TomlConfigParser): + """TOML labconfig reader with the small ConfigParser-like API used by the suite.""" + NoOptionError = configparser.NoOptionError NoSectionError = configparser.NoSectionError + DuplicateSectionError = configparser.DuplicateSectionError def __init__( self, config_path=default_config_path, required_params=None, defaults=None, @@ -43,12 +121,20 @@ def __init__( required_params = {} if defaults is None: defaults = {} - # str() below is for py36 compat, where ConfigParser can't deal with Path objs + else: + defaults = dict(defaults) defaults['labscript_suite'] = str(LABSCRIPT_SUITE_PROFILE) if isinstance(config_path, list): - self.config_path = config_path[0] + config_paths = list(config_path) + if config_paths: + if config_paths[0] == default_config_path: + config_paths[0] = ensure_labconfig() + self.config_path = config_paths[0] + else: + self.config_path = None else: - self.config_path = config_path + self.config_path = ensure_labconfig() if config_path == default_config_path else config_path + config_paths = [self.config_path] self.file_format = "" for section, options in required_params.items(): @@ -56,77 +142,190 @@ def __init__( for option in options: self.file_format += "%s = \n" % option - # Load the config file - configparser.ConfigParser.__init__( - self, defaults=defaults, interpolation=EnvInterpolation() + TomlConfigParser.__init__( + self, + defaults=defaults, + interpolation=EnvInterpolation(), ) - # read all files in the config path if it is a list (self.config_path only - # contains one string): - self.read(config_path) + for path in config_paths: + self._read_path(path) - # Rename experiment_name to apparatus_name and raise a DeprectionWarning - experiment_name = self.get("DEFAULT", "experiment_name", fallback=None) + experiment_name = self.get("default", "experiment_name", fallback=None) if experiment_name: msg = """The experiment_name keyword has been renamed apparatus_name in labscript_utils 3.0, and will be removed in a future version. Please update your labconfig to use the apparatus_name keyword.""" warnings.warn(dedent(msg), FutureWarning) - if self.get("DEFAULT", "apparatus_name", fallback=None): + if self.get("default", "apparatus_name", fallback=None): msg = """You have defined both experiment_name and apparatus_name in your labconfig. Please omit the deprecate experiment_name keyword.""" raise Exception(dedent(msg)) - else: - self.set("DEFAULT", "apparatus_name", experiment_name) + self.set("default", "apparatus_name", experiment_name) try: for section, options in required_params.items(): + section = self._canonical_section_name(section) for option in options: self.get(section, option) - except configparser.NoOptionError: + except (configparser.NoOptionError, configparser.NoSectionError): msg = f"""The experiment configuration file located at {config_path} does not have the required keys. Make sure the config file contains the following structure:\n{self.file_format}""" raise Exception(dedent(msg)) + def _read_path(self, path): + """Read a TOML labconfig, or temporarily fall back to legacy INI.""" + if path is None: + return + path = Path(path) + if not path.exists(): + return + if path.suffix.lower() == '.toml': + self.read_toml(path) + return + # LEGACY INI COMPATIBILITY. DEPRECATED CODE, WILL BE REMOVED. + if path.suffix.lower() == '.ini': + msg = """INI labconfig support is deprecated and slated for removal soon. + Convert this file to TOML.""" + warnings.warn(dedent(msg), FutureWarning) + self.read(path) + return + msg = f"Unsupported labconfig format for {path}. Expected a .toml or .ini file." + raise RuntimeError(msg) -def save_appconfig(filename, data): - """Save a dictionary as an ini file. The keys of the dictionary comprise the section - names, and the values must themselves be dictionaries for the names and values - within each section. All section values will be converted to strings with - pprint.pformat().""" - # Error checking - for section_name, section in data.items(): - for name, value in section.items(): - try: - valid = value == literal_eval(pformat(value)) - except (ValueError, SyntaxError): - valid = False - if not valid: - msg = f"{section_name}/{name} value {value} not a Python built-in type" + +def _resolve_appconfig_load_path(filename): + path = Path(filename) + suffix = path.suffix.lower() + # LEGACY INI COMPATIBILITY. DEPRECATED CODE, WILL BE REMOVED. + if suffix == '.ini': + toml_path = path.with_suffix('.toml') + if toml_path.exists(): + return toml_path + if path.exists(): + return path + return path + if suffix == '.toml': + if path.exists(): + return path + # LEGACY INI COMPATIBILITY. DEPRECATED CODE, WILL BE REMOVED. + legacy_path = path.with_suffix('.ini') + if legacy_path.exists(): + return legacy_path + return path + toml_path = path.with_suffix('.toml') + if toml_path.exists(): + return toml_path + # LEGACY INI COMPATIBILITY. DEPRECATED CODE, WILL BE REMOVED. + legacy_path = path.with_suffix('.ini') + if legacy_path.exists(): + return legacy_path + return toml_path + + +def _resolve_appconfig_save_path(filename): + return Path(filename).with_suffix('.toml') + +# LEGACY INI COMPATIBILITY. DEPRECATED CODE, WILL BE REMOVED. +def backup_legacy_config(path): + """Rename a migrated legacy config file to ``.old`` without clobbering backups.""" + path = Path(path) + if not path.exists(): + return None + backup_path = Path(str(path) + '.old') + index = 1 + while backup_path.exists(): + backup_path = Path(str(path) + '.old' + str(index)) + index += 1 + try: + path.rename(backup_path) + except OSError: + return None + return backup_path + + +def _to_toml_compatible(value, location='value'): + if isinstance(value, dict): + converted = {} + for key, child in value.items(): + if not isinstance(key, str): + msg = f"{location} contains a non-string dict key {key!r}" raise TypeError(msg) + converted[key] = _to_toml_compatible(child, f"{location}.{key}") + return converted + if isinstance(value, (list, tuple)): + return [ + _to_toml_compatible(child, f"{location}[{index}]") + for index, child in enumerate(value) + ] + if isinstance(value, (str, bool, int, float)): + return value + msg = f"{location} value {value!r} is not representable in TOML app config" + raise TypeError(msg) + +# LEGACY INI COMPATIBILITY. DEPRECATED CODE, WILL BE REMOVED. +def _load_legacy_appconfig_value(value): + """Temporary legacy INI app-config decoder. Slated for removal soon.""" + if not isinstance(value, str): + return value + try: + return literal_eval(value) + except (ValueError, SyntaxError): + return value + + +def save_appconfig(filename, data): + """Save a dictionary as a TOML app config.""" data = { - section_name: {name: pformat(value) for name, value in section.items()} + section_name: { + name: _to_toml_compatible(value, f"{section_name}/{name}") + for name, value in section.items() + } for section_name, section in data.items() } - c = configparser.ConfigParser(interpolation=None) - c.optionxform = str # preserve case - c.read_dict(data) - Path(filename).parent.mkdir(parents=True, exist_ok=True) - with open(filename, 'w') as f: - c.write(f) - - -def load_appconfig(filename): - """Load an .ini file and return a dictionary of its contents. All values will be - converted to Python objects with ast.literal_eval(). All keys will be lowercase - regardless of the written contents on the .ini file.""" - c = configparser.ConfigParser(interpolation=None) - c.optionxform = str # preserve case - # No file? No config - don't crash. - if Path(filename).exists(): - c.read(filename) - return { - section_name: {name: literal_eval(value) for name, value in section.items()} - for section_name, section in c.items() - } + filename = _resolve_appconfig_save_path(filename) + filename.parent.mkdir(parents=True, exist_ok=True) + dump_toml_file(filename, data) + return str(filename) + + +def load_appconfig(filename, return_save_path=False): + """Load a TOML app config, or a legacy INI app config if required. + + If a legacy INI file is loaded, a sibling TOML file is immediately written and can be + returned as the canonical save path via ``return_save_path=True``. After successful + conversion, the legacy INI file is moved aside to ``.old`` so future loads prefer the + canonical TOML file. Legacy INI support here is temporary and slated for removal soon. + """ + requested_path = Path(filename) + filename = _resolve_appconfig_load_path(filename) + save_path = _resolve_appconfig_save_path(requested_path) + # LEGACY INI COMPATIBILITY. DEPRECATED CODE, WILL BE REMOVED. + if filename.suffix.lower() == '.ini': + c = configparser.ConfigParser(interpolation=None) + c.optionxform = str + if filename.exists(): + c.read(filename) + data = { + section_name: { + name: _load_legacy_appconfig_value(value) + for name, value in c.items(section_name, raw=True) + } + for section_name in c.sections() + } + if filename.exists(): + save_path = Path(save_appconfig(save_path, data)) + backup_legacy_config(filename) + else: + raw = load_toml_file(filename) if filename.exists() else {} + data = { + section_name: dict(section.items()) + for section_name, section in raw.items() + if section_name != 'default' and isinstance(section, dict) + } + if filename.suffix.lower() == '.toml': + save_path = filename + if return_save_path: + return data, str(save_path) + return data diff --git a/labscript_utils/lookup_format.py b/labscript_utils/lookup_format.py new file mode 100644 index 00000000..51d1cb73 --- /dev/null +++ b/labscript_utils/lookup_format.py @@ -0,0 +1,173 @@ +##################################################################### +# # +# lookup_format.py # +# # +# Copyright 2026, Monash University # +# # +# This file is part of the labscript suite (see # +# http://labscriptsuite.org) and is licensed under the Simplified # +# BSD License. See the license.txt file in the root of the project # +# for the full license. # +# # +##################################################################### +import ast +import string + + +class InvalidLookupFormatField(ValueError): + """Raised when a lookup-format field is not valid lookup-only syntax.""" + + +class _UnresolvedLookupError(LookupError): + """Raised when a lookup could not be resolved from the provided context.""" + + def __init__(self, root_name): + LookupError.__init__(self, root_name) + self.root_name = root_name + + +def format_lookup_string( + template, + context, + dt=None, + preserve_unresolved=False, + preserve_unresolved_roots=(), +): + """Format a template using optional ``strftime`` plus lookup-only fields. + + Args: + template (str): Format template. + context (dict): Top-level lookup context. + dt (datetime, optional): Datetime used for a first ``strftime`` pass. + preserve_unresolved (bool): If True, unresolved lookups are left intact. + preserve_unresolved_roots (iterable): Root names that may be preserved when a + lookup below them is unresolved. + """ + if dt is not None: + template = dt.strftime(template) + formatter = _LookupFormatter( + context, + preserve_unresolved=preserve_unresolved, + preserve_unresolved_roots=preserve_unresolved_roots, + ) + return formatter.format(template) + + +class _LookupFormatter(object): + def __init__( + self, + context, + preserve_unresolved=False, + preserve_unresolved_roots=(), + ): + self.context = context + self.preserve_unresolved = preserve_unresolved + self.preserve_unresolved_roots = set(preserve_unresolved_roots) + self.formatter = string.Formatter() + + def format(self, template): + parts = [] + for literal_text, field_name, format_spec, conversion in self.formatter.parse(template): + parts.append(literal_text) + if field_name is None: + continue + placeholder = self._reconstruct_placeholder( + field_name, conversion, format_spec + ) + try: + value = self._resolve_field(field_name) + if conversion: + value = self.formatter.convert_field(value, conversion) + if format_spec: + format_spec = self.format(format_spec) + parts.append(self.formatter.format_field(value, format_spec)) + except _UnresolvedLookupError as exc: + if self.preserve_unresolved or exc.root_name in self.preserve_unresolved_roots: + parts.append(placeholder) + else: + raise + return ''.join(parts) + + def _resolve_field(self, field_name): + root_name, lookups = _parse_lookup_field(field_name) + try: + value = self.context[root_name] + except KeyError: + raise _UnresolvedLookupError(root_name) + for lookup in lookups: + try: + value = value[lookup] + except (IndexError, KeyError, TypeError): + raise _UnresolvedLookupError(root_name) + return value + + @staticmethod + def _reconstruct_placeholder(field_name, conversion, format_spec): + placeholder = '{' + field_name + if conversion: + placeholder += '!' + conversion + if format_spec: + placeholder += ':' + format_spec + placeholder += '}' + return placeholder + + +def _parse_lookup_field(field_name): + field_name = str(field_name) + length = len(field_name) + index = 0 + + root_name, index = _parse_identifier(field_name, index) + lookups = [] + + while index < length: + if field_name[index] != '[': + msg = "Invalid lookup-format field %r" % field_name + raise InvalidLookupFormatField(msg) + closing_index = field_name.find(']', index + 1) + if closing_index == -1: + msg = "Invalid lookup-format field %r" % field_name + raise InvalidLookupFormatField(msg) + lookup_text = field_name[index + 1 : closing_index].strip() + if not lookup_text: + msg = "Invalid lookup-format field %r" % field_name + raise InvalidLookupFormatField(msg) + lookups.append(_parse_lookup_key(lookup_text)) + index = closing_index + 1 + + return root_name, lookups + + +def _parse_identifier(field_name, index): + if index >= len(field_name): + msg = "Invalid lookup-format field %r" % field_name + raise InvalidLookupFormatField(msg) + + start = index + first = field_name[index] + if not (first.isalpha() or first == '_'): + msg = "Invalid lookup-format field %r" % field_name + raise InvalidLookupFormatField(msg) + index += 1 + + while index < len(field_name): + char = field_name[index] + if char.isalnum() or char == '_': + index += 1 + continue + break + + return field_name[start:index], index + + +def _parse_lookup_key(lookup_text): + if lookup_text[0] in ('"', "'"): + try: + return ast.literal_eval(lookup_text) + except (SyntaxError, ValueError) as exc: + msg = "Invalid lookup-format key %r" % lookup_text + raise InvalidLookupFormatField(msg) from exc + try: + return int(lookup_text) + except ValueError: + return lookup_text diff --git a/labscript_utils/ls_zprocess.py b/labscript_utils/ls_zprocess.py index 4d9e9575..29c94d52 100644 --- a/labscript_utils/ls_zprocess.py +++ b/labscript_utils/ls_zprocess.py @@ -257,7 +257,7 @@ def socket(self, socket_type, socket_class=None, **kwargs): # be a SecureSocket. If caller has explicitly requested a different socket type # (e.g since pyzmq 25, ThreadAuthenticator sets up an internal socket by calling # `Context.socket(..., socket_class=zmq.Socket)), then don't.` - if socket_class is None or issubclass(socket_class, SecureContext): + if socket_class is None or issubclass(socket_class, SecureSocket): config = get_config() kwargs['allow_insecure'] = config['allow_insecure'] return SecureContext.socket(self, socket_type=socket_type, **kwargs) @@ -383,4 +383,3 @@ def ensure_connected_to_zlog(): else: client.ping() _connected_to_zlog = True - diff --git a/labscript_utils/plugins.py b/labscript_utils/plugins.py new file mode 100644 index 00000000..46306648 --- /dev/null +++ b/labscript_utils/plugins.py @@ -0,0 +1,1566 @@ +##################################################################### +# # +# plugins.py # +# # +# Copyright 2013, Monash University # +# # +# This file is part of the labscript suite (see # +# http://labscriptsuite.org) and is licensed under the Simplified # +# BSD License. See the license.txt file in the root of the project # +# for the full license. # +# # +##################################################################### + +"""Plugin discovery, instantiation, and callback plumbing for labscript apps. + +This module is the small framework layer that labscript-suite applications use +to discover plugins, instantiate them, collect app-neutral contributions, and +ask them for callbacks at runtime. The application owns the actual settings +storage, notification manager, menu objects, UI containers, and event firing. +``PluginManager`` only calls plugin hooks and routes contributions to contexts +the application registered explicitly. + +The usual package layout is:: + + myapp/ + plugins/ + __init__.py + example/ + __init__.py # defines class Plugin + +The module name becomes the plugin name. ``PluginManager`` looks for a module +attribute named ``Plugin`` and passes the saved per-plugin settings dictionary +to ``Plugin(initial_settings)``. Existing plugins do not have to inherit +``BasePlugin``; they only need to provide the same methods by duck typing. + +Configuration behavior is intentionally conservative: + +* Missing plugin entries are added to the config when discovered. +* Names listed in ``default_plugins`` are written as enabled by default. +* Only enabled modules are imported. +* Import or instantiation failures are logged and skipped so one broken plugin + does not stop the application from starting. + +``Callback`` and ``callback`` wrap event handlers with priority metadata. +Lower priority numbers run first. ``Callback`` behaves like a descriptor, so +decorated methods still bind to plugin instances normally when accessed through +an instance. + +Two menu paths are supported, intentionally with different contracts: + +* ``MenuBuilder`` is the legacy nested-dictionary renderer used by existing + BLACS-style plugins. It consumes dictionaries with ``name``, ``menu_items``, + ``icon``, ``action``, and ``separator`` keys and renders them immediately + into application menu objects. +* ``MenuContext`` is the shared contribution path for future applications. It + collects actions with explicit ``location``, ``path``, ``group``, ``order``, + ``name``, and ``action`` metadata, then renders all plugins together after + contribution routing is complete. + +``labscript_utils.plugins`` does not know whether a plugin UI belongs in a +tab, MDI subwindow, dock widget, dialog, fixed frame, or plugin-owned modal +container. Plugins declare a named ``context`` and applications register +objects that implement ``add(plugin_name, contribution, data)``. That is the +full shared UI contract. + +Minimal application integration example +--------------------------------------- + +The application owns plugin settings storage, concrete UI objects, and context +implementations. A compact integration with legacy settings/notifications plus +new contribution routing looks like this:: + + from labscript_utils.plugins import MenuBuilder, MenuContext, PluginManager + + + class App(object): + def __init__(self, config, logger): + self.config = config + self.logger = logger + self.plugin_manager = PluginManager( + plugin_package='myapp.plugins', + plugins_dir='/path/to/myapp/plugins', + config=self.config, + config_section='myapp/plugins', + default_plugins=('example',), + logger=self.logger, + ) + + self.plugin_manager.discover_modules() + + plugin_settings = self.load_plugin_settings() + self.plugin_manager.instantiate_plugins(plugin_settings) + + menu_builder = MenuBuilder(icon_factory=self.icon_factory) + settings_pages, settings_callbacks = self.plugin_manager.setup_plugins( + data=self.app_data, + notifications=self.notification_manager, + menu_builder=menu_builder, + menubar=self.menubar, + ) + + menus = MenuContext(icon_factory=self.icon_factory, logger=self.logger) + menus.register_location('file', self.file_menu) + menus.register_location('window', self.window_menu) + + self.plugin_manager.register_context('menus', menus) + self.plugin_manager.register_context('mdi', MDIContext(self.mdi_area)) + self.plugin_manager.register_context( + 'dialogs', + DialogContext(parent=self.main_window), + ) + self.plugin_manager.setup_contexts(self.app_data) + menus.render() + + self.settings_widget = self.build_settings_ui(settings_pages) + for callback in settings_callbacks: + self.register_settings_callback(callback) + + self.plugin_manager.setup_complete(self.app_data) + + def load_plugin_settings(self): + # App-owned storage. Return a mapping from plugin name to dict. + return { + 'example': {'enabled': True}, + } + + def build_settings_ui(self, settings_pages): + # App-owned settings UI. Each item in settings_pages is a plugin + # contributed settings page class. + return settings_pages + + def register_settings_callback(self, callback): + # App-owned settings wiring. + self.settings_changed_callbacks.append(callback) + + def fire_event(self, name): + # App-owned event firing. PluginManager only returns callbacks. + for callback in self.plugin_manager.get_callbacks(name): + callback() + + def close_plugins(self): + self.plugin_manager.close_plugins() + + +Minimal plugin package example +------------------------------ + +This example shows the shared plugin surface. The plugin contributes a legacy +menu, a contribution-based menu action, a settings page, a notification, an MDI +window request, callbacks, ``setup_complete()`` handling, save data, and +shutdown cleanup. Methods can return empty lists or dictionaries when a feature +is not used:: + + from labscript_utils.plugins import BasePlugin, callback + + + class SettingsPage(object): + pass + + + class Notification(object): + pass + + + class DataBrowser(object): + def __init__(self, parent, services): + self.parent = parent + self.services = services + + + class Menu(object): + def __init__(self, data): + self.data = data + + def get_menu_items(self): + return { + 'name': 'Example', + 'menu_items': [ + {'name': 'Legacy Open', 'action': self.open_action}, + {'separator': True}, + { + 'name': 'Legacy Settings', + 'icon': 'gear', + 'action': self.settings_action, + }, + ], + } + + def open_action(self): + pass + + def settings_action(self): + pass + + + class Plugin(BasePlugin): + def __init__(self, initial_settings): + super(Plugin, self).__init__(initial_settings) + self.saved_state = initial_settings + self.app_data = None + + def get_menu_class(self): + return Menu + + def get_menu_contributions(self): + return [ + { + 'location': 'file', + 'path': ('Examples',), + 'group': 'open', + 'order': 20, + 'name': 'Open Data Browser', + 'shortcut': 'Ctrl+D', + 'icon': 'folder-open', + 'action': self.open_data_browser, + }, + ] + + def get_ui_contributions(self): + return [ + { + 'context': 'mdi', + 'key': 'data_browser', + 'title': 'Data Browser', + 'factory': DataBrowser, + }, + ] + + def get_notification_classes(self): + return [Notification] + + def get_setting_classes(self): + return [SettingsPage] + + def get_event_handlers(self): + return { + 'settings_changed': self.on_settings_changed, + 'shot_complete': self.on_shot_complete, + } + + def plugin_setup_complete(self, data): + self.app_data = data + + def get_save_data(self): + return {'saved_state': self.saved_state} + + def close(self): + pass + + def open_data_browser(self): + pass + + @callback(priority=5) + def on_settings_changed(self): + pass + + @callback(priority=20) + def on_shot_complete(self): + pass + + +Feature excerpts and expected UI +-------------------------------- + +BasePlugin subclass with saved ``initial_settings`` and ``get_save_data()`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The constructor receives the per-plugin saved settings dictionary. Store it if +you need to restore state later. ``get_save_data()`` should return the +serializable data you want written back for the next application start:: + + class Plugin(BasePlugin): + def __init__(self, initial_settings): + super(Plugin, self).__init__(initial_settings) + self.initial_settings = initial_settings + + def get_save_data(self): + return { + 'window_geometry': self.window_geometry, + 'last_path': self.last_path, + } + +Settings page contribution skeleton +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``get_setting_classes()`` returns the page classes the application should add +to its settings UI. The application owns the widget construction and storage:: + + class Plugin(BasePlugin): + def get_setting_classes(self): + return [SettingsPage] + + class SettingsPage(object): + pass + +Expected UI result: the application shows one settings page for each returned +class, using the app's own settings shell and persistence rules. + +Legacy menu skeleton with MenuBuilder +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``get_menu_class()`` returns a class whose instance exposes +``get_menu_items()``. The nested dictionaries are translated into the app's +menu objects by ``MenuBuilder``. It expects menus and actions to support +``addMenu()``, ``addAction()``, and ``addSeparator()``:: + + class Plugin(BasePlugin): + def get_menu_class(self): + return Menu + + class Menu(object): + def __init__(self, data): + self.data = data + + def get_menu_items(self): + return { + 'name': 'Example', + 'menu_items': [ + {'name': 'Open', 'action': self.open_action}, + {'separator': True}, + { + 'name': 'Settings', + 'icon': 'gear', + 'action': self.settings_action, + }, + ], + } + + def open_action(self): + pass + + def settings_action(self): + pass + +Expected UI result: the app user sees a top-level menu labeled ``Example``, +with an ``Open`` action, a separator, and a ``Settings`` action. If the app +supplies ``icon_factory``, the ``Settings`` action can also show an icon. + +Contribution menu skeleton with MenuContext +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``get_menu_contributions()`` returns dictionaries routed to the application +``menus`` context. ``MenuContext`` understands these keys: + +* ``location``: app-registered top-level menu id, such as ``file`` or + ``window`` +* ``path``: optional submenu path below that location; defaults to ``()`` +* ``group``: separator group within the same location/path; defaults to + ``None`` +* ``order``: numeric order within the group; defaults to ``DEFAULT_PRIORITY`` +* ``name``: action text +* ``action``: callable connected to the action's ``triggered`` signal +* ``shortcut``, ``icon``, ``checkable``, and ``enabled``: optional action + properties applied when the concrete action object supports them + +File-dialog-style plugin example:: + + class FileDialogsPlugin(BasePlugin): + def __init__(self, initial_settings): + super(FileDialogsPlugin, self).__init__(initial_settings) + self.services = None + + def plugin_setup_complete(self, data): + self.services = data['project_services'] + + def get_menu_contributions(self): + return [ + { + 'location': 'file', + 'group': 'project', + 'order': 10, + 'name': 'New Project', + 'shortcut': 'Ctrl+N', + 'action': self.new_project, + }, + { + 'location': 'file', + 'group': 'project', + 'order': 20, + 'name': 'Open Project...', + 'shortcut': 'Ctrl+O', + 'action': self.open_project, + }, + { + 'location': 'file', + 'group': 'save', + 'order': 10, + 'name': 'Save', + 'shortcut': 'Ctrl+S', + 'action': self.save_project, + }, + { + 'location': 'file', + 'group': 'save', + 'order': 20, + 'name': 'Save As...', + 'shortcut': 'Ctrl+Shift+S', + 'action': self.save_project_as, + }, + { + 'location': 'file', + 'group': 'application', + 'order': 100, + 'name': 'Quit', + 'shortcut': 'Ctrl+Q', + 'action': self.quit_application, + }, + ] + + def new_project(self): + self.services.new_project_dialog.open() + + def open_project(self): + self.services.load_project_dialog.open() + + def save_project(self): + self.services.save_project_command.execute() + + def save_project_as(self): + self.services.save_as_project_dialog.open() + + def quit_application(self): + self.services.quit_command.execute() + +Expected UI result: the application renders the actions in its registered +``file`` menu, creates separators between the ``project``, ``save``, and +``application`` groups, and applies shortcuts without the shared framework +knowing what a project is. + +App-neutral UI homing with contexts +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``get_ui_contributions()`` returns dictionaries with a required ``context``. +The rest of the dictionary is application-defined. This is enough to support +known containers, deferred containers, and plugin-provided containers without +adding type-specific methods such as ``create_tabs()`` or +``create_mdi_windows()`` to ``PluginManager``:: + + class KnownContainerContext(object): + def __init__(self, tab_widget): + self.tab_widget = tab_widget + + def add(self, plugin_name, contribution, data): + widget = contribution['factory']( + self.tab_widget, + data['settings'][contribution['key']], + ) + self.tab_widget.addTab(widget, contribution['title']) + + + class DeferredMDIContext(object): + def __init__(self, mdi_area): + self.mdi_area = mdi_area + self.openers = {} + + def add(self, plugin_name, contribution, data): + key = contribution['key'] + + def open_window(): + widget = contribution['factory'](parent=self.mdi_area, data=data) + self.mdi_area.addSubWindow(widget) + widget.show() + return widget + + self.openers[key] = open_window + + + class DialogContext(object): + def __init__(self, parent): + self.parent = parent + self.dialogs = {} + + def add(self, plugin_name, contribution, data): + self.dialogs[contribution['key']] = contribution['factory']( + parent=self.parent, + services=data['services'], + ) + + + class Plugin(BasePlugin): + def get_ui_contributions(self): + return [ + { + 'context': 'tabs', + 'key': 'overview', + 'title': 'Overview', + 'factory': OverviewWidget, + }, + { + 'context': 'mdi', + 'key': 'data_browser', + 'title': 'Data Browser', + 'factory': DataBrowser, + }, + { + 'context': 'dialogs', + 'key': 'open_project', + 'title': 'Open Project', + 'factory': OpenProjectDialog, + }, + ] + +Expected UI result: each application-owned context decides how to instantiate, +store, parent, restore, defer, activate, or display its contribution. Unknown +contexts are logged and skipped by ``PluginManager.setup_contexts()``. + +Notification contribution skeleton +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``get_notification_classes()`` returns notification classes. The application +owns the notification manager and decides how the resulting widgets are shown:: + + class Plugin(BasePlugin): + def get_notification_classes(self): + return [Notification] + + class Notification(object): + pass + +Expected UI result: the application creates whatever notification widgets or +panels it normally uses for those classes and inserts them into its own +notification area. + +Callbacks with priorities +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``@callback(priority=...)`` wraps a method in ``Callback`` metadata. Lower +numbers run first when the application asks ``PluginManager.get_callbacks()``:: + + class SlowPlugin(BasePlugin): + def get_event_handlers(self): + return { + 'shot_complete': self.on_slow, + } + + @callback(priority=20) + def on_slow(self): + pass + + class FastPlugin(BasePlugin): + def get_event_handlers(self): + return { + 'shot_complete': self.on_fast, + } + + @callback(priority=5) + def on_fast(self): + pass + +Expected runtime order: when the application fires ``shot_complete`` and calls +``plugin_manager.get_callbacks('shot_complete')``, the priority 5 callback +runs before the priority 20 callback, even if the plugins were discovered in a +different order. + +``setup_complete()`` for app data +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``setup_complete()`` is called after the application has finished startup. +Use it to access app-owned state that was not available during construction, +such as the main window, settings object, registered services, or concrete +context objects. Existing plugins can keep overriding +``plugin_setup_complete()``:: + + class Plugin(BasePlugin): + def plugin_setup_complete(self, data): + self.app = data['app'] + self.settings = data['settings'] + self.services = data['services'] + +The ``data`` dictionary is app-defined. Old plugins may still define +``plugin_setup_complete(self)`` without the ``data`` argument, and the manager +keeps that compatibility path. + +Plugins that need ordered startup work can return setup activity records from +``get_setup_activities()``. Each record has ``name``, ``priority``, and +``action`` keys; lower priority values run first. The default +``get_setup_activities()`` implementation returns one activity for +``plugin_setup_complete()``, so existing plugins use the same ordered pipeline +as modern plugins:: + + class Plugin(BasePlugin): + def get_setup_activities(self): + return [ + { + 'name': 'bind_services', + 'priority': DEFAULT_SETUP_PRIORITY, + 'action': self.bind_services, + }, + { + 'name': 'start_worker', + 'priority': DEFAULT_SETUP_PRIORITY + 10, + 'action': self.start_worker, + }, + ] + +Shutdown cleanup with ``close()`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``close()`` is the plugin shutdown hook. Use it to stop timers, threads, +listeners, or any other plugin-owned resources:: + + class Plugin(BasePlugin): + def close(self): + self.worker.stop() + self.worker.wait() + +Expected behavior: the application calls each plugin's ``close()`` method +during shutdown so the plugin can release resources before exit. +""" + +import importlib +import logging +import os +import warnings +from collections.abc import Mapping +from types import MethodType + + +DEFAULT_PRIORITY = 10 +DEFAULT_SETUP_PRIORITY = 0 + +__all__ = [ + 'DEFAULT_PRIORITY', + 'DEFAULT_SETUP_PRIORITY', + 'Callback', + 'callback', + 'BasePlugin', + 'MenuBuilder', + 'MenuContext', + 'PluginManager', +] + + +def _log_once(logger, seen, level, key, message): + """Log ``message`` once for each hashable ``key``.""" + if key in seen: + return + seen.add(key) + logger.log(level, message) + + +class Callback(object): + """Wrap a callable with priority metadata and method-style binding. + + ``priority`` is used when ``PluginManager.get_callbacks()`` gathers all + callbacks for a named event. Lower numbers run first. + + The descriptor protocol is implemented so that a ``Callback`` stored as a + class attribute binds like an instance method when accessed from a plugin + instance. This keeps decorated methods usable without extra wrapper code. + """ + def __init__(self, func, priority=DEFAULT_PRIORITY): + self.priority = priority + self.func = func + + def __get__(self, instance, class_): + """Bind to ``instance`` the same way a normal function descriptor does.""" + if instance is None: + return self + else: + return MethodType(self, instance) + + def __call__(self, *args, **kwargs): + return self.func(*args, **kwargs) + + +class callback(object): + """Decorator that turns a function into a :class:`Callback`. + + The decorator is optional. A plain method can still be returned from + ``get_callbacks()`` if it already behaves like a callback. Use the + decorator when you want to attach a non-default priority or make the + callback object explicit in the class body. + """ + # Instantiate the decorator: + def __init__(self, priority=DEFAULT_PRIORITY): + self.priority = priority + + # Call the decorator + def __call__(self, func): + return Callback(func, self.priority) + + +class BasePlugin(object): + """Reference implementation of the plugin interface. + + Subclass this when you want a concrete starting point, but it is not + required. The manager only relies on the methods defined here; a plugin + can also satisfy the contract by duck typing. + + ``initial_settings`` contains the saved configuration for the plugin. + """ + def __init__(self, initial_settings): + """Store the plugin's saved settings. + + Applications pass the dictionary returned by the previous + ``get_save_data()`` call, or an empty dictionary for a first run. + Subclasses commonly keep this dictionary and use it during + ``plugin_setup_complete()`` to restore UI state or background state. + """ + self.initial_settings = initial_settings + self.menu = None + self.notifications = {} + + def get_menu_class(self): + """Return a menu class for this plugin, or ``None``. + + Deprecated compatibility hook for BLACS-style nested menus. + + The class is constructed as ``MenuClass(data)`` during + ``PluginManager.setup_plugins()``. Its instance should provide + ``get_menu_items()``, returning the nested menu dictionary consumed by + :class:`MenuBuilder`. New shared plugin code should prefer + :meth:`get_menu_contributions` instead. + """ + return None + + def get_notification_classes(self): + """Return notification classes contributed by this plugin. + + The application-specific notification manager constructs the + notifications. Instances are later passed back to + ``set_notification_instances()`` in a dictionary keyed by notification + class. + """ + return [] + + def get_setting_classes(self): + """Return settings-page classes contributed by this plugin. + + The application owns the settings UI and storage. The manager only + collects these classes and returns them to the application from + ``setup_plugins()``. + """ + return [] + + def get_event_handlers(self): + """Return a mapping of event names to handlers, or ``None``. + + This is the preferred shared event hook surface. Return a mapping such + as ``{'shot_complete': self.on_shot_complete}``. Event names are + application-defined strings; this shared layer only collects, + normalizes, and orders handlers. Values may be plain callables or + :class:`Callback` instances created with the :class:`callback` + decorator. The manager sorts handlers for a given event by their + ``priority`` attribute. + """ + return None + + def get_callbacks(self): + """Return callbacks keyed by event name, or ``None``. + + Deprecated compatibility alias for :meth:`get_event_handlers`. + + Existing BLACS-style plugins may still override this method. New + shared plugin code should implement :meth:`get_event_handlers` + instead. ``BasePlugin`` routes this legacy name to the modern hook so + the preferred surface is the real implementation path. + """ + return self.get_event_handlers() + + def get_ui_contributions(self): + """Return app-context UI contributions. + + Return an iterable of dictionaries. Each contribution has a + ``context`` key naming an application-registered context. The + application owns what that context means: a known tab area, a deferred + MDI workspace, a dialog registry, a fixed frame, or another + app-specific host. The remaining keys are application-defined. + """ + return [] + + def get_menu_contributions(self): + """Return shared menu contributions. + + Return an iterable of dictionaries. This is the app-neutral menu path. + Existing BLACS-style plugins can continue using ``get_menu_class()`` + and :class:`MenuBuilder`; new apps can register a ``menus`` context and + route these dictionaries through :class:`MenuContext`. Apart from + stable menu keys such as ``location``, ``path``, ``group``, ``order``, + ``name``, and ``action``, the concrete menu objects remain + application-owned. + """ + return [] + + def set_menu_instance(self, menu): + """Receive the menu instance constructed for this plugin.""" + self.menu = menu + + def set_notification_instances(self, notifications): + """Receive notification instances constructed for this plugin.""" + self.notifications = notifications + + def plugin_setup_complete(self, data=None): + """Run after the application has finished plugin setup. + + ``data`` is an application-defined dictionary. Plugins commonly store + references to services or state they will need later when handling + events, building menus, or opening UI. For BLACS it contains + references such as the main UI, experiment config, plugin mapping, and + settings object. Older plugins may define this method without a + ``data`` argument; ``PluginManager.setup_complete()`` keeps that + compatibility behavior. + """ + pass + + def get_setup_activities(self): + """Return ordered setup activities for this plugin. + + Existing plugins can override :meth:`plugin_setup_complete` and inherit + this method. Modern plugins can override this method to return multiple + activity records. Each record must provide ``name``, ``priority``, and + ``action`` keys; the manager executes all plugin activities through one + ordered setup pipeline. + """ + return [ + { + 'name': 'plugin_setup_complete', + 'priority': DEFAULT_SETUP_PRIORITY, + 'action': self.plugin_setup_complete, + }, + ] + + def get_save_data(self): + """Return serializable plugin state for the next application start. + + The shape of this data is application-owned. Return plain data + structures that the application can persist and pass back as + ``initial_settings`` during the next start. + """ + return {} + + def get_services(self): + """Return named services exposed by this plugin. + + Return a mapping of service names to concrete objects. Applications can + aggregate these mappings into a shared service registry before calling + :meth:`plugin_setup_complete`, making plugin-to-plugin dependencies + order-independent. + """ + return {} + + def close(self): + """Clean up resources owned by the plugin during application shutdown. + + Applications call this during shutdown. Stop timers, workers, + listeners, or other plugin-owned resources here. + """ + pass + + +class MenuBuilder(object): + """Build menus from the nested dictionary format used by plugins. + + Each menu description is a dictionary. Supported keys are: + + * ``name``: text for a menu or action + * ``menu_items``: child menu descriptions, making a submenu + * ``icon``: icon name passed to ``icon_factory`` for actions + * ``action``: callable connected to the action's ``triggered`` signal + * ``separator``: a truthy sentinel that inserts a separator + + ``icon_factory`` is optional and should return a Qt icon object or other + value accepted by ``addAction``. Supplying it keeps this module free of a + direct Qt dependency. + """ + def __init__(self, icon_factory=None): + """Create a menu builder. + + Args: + icon_factory (callable, optional): Function called as + ``icon_factory(icon_name)`` before adding an icon-bearing menu + action. Applications using Qt typically pass ``QIcon``. + """ + self.icon_factory = icon_factory + + def create_menu(self, parent, menu_parameters): + """Recursively build menus and actions from a nested menu dictionary.""" + if 'name' in menu_parameters: + if 'menu_items' in menu_parameters: + child = parent.addMenu(menu_parameters['name']) + for child_menu_params in menu_parameters['menu_items']: + self.create_menu(child, child_menu_params) + else: + # ``icon_factory`` stays outside this module so labscript-utils + # does not need to import a Qt binding directly. + if 'icon' in menu_parameters and self.icon_factory is not None: + child = parent.addAction( + self.icon_factory(menu_parameters['icon']), + menu_parameters['name'], + ) + else: + child = parent.addAction(menu_parameters['name']) + + if 'action' in menu_parameters: + child.triggered.connect(menu_parameters['action']) + + elif 'separator' in menu_parameters: + parent.addSeparator() + + +class MenuContext(object): + """Collect and render contribution-based menu actions. + + Applications register stable top-level menu locations, such as ``file`` or + ``tools``. Plugins contribute action dictionaries that request a location, + optional submenu path, separator group, order, action text, and callback. + Rendering is deferred until all plugins have contributed so ordering and + group separators can be computed across plugins. + """ + def __init__(self, icon_factory=None, logger=None): + """Create a menu context. + + Args: + icon_factory (callable, optional): Function called as + ``icon_factory(icon_name)`` for icon-bearing actions. + logger (logging.Logger, optional): Logger used for skipped or + malformed menu contributions. + """ + self.icon_factory = icon_factory + self.logger = logger or logging.getLogger(__name__) + self.locations = {} + self.contributions = [] + + def register_location(self, name, menu): + """Register an application-owned menu object as a stable location.""" + self.locations[name] = menu + + def add(self, plugin_name, contribution, data): + """Collect one menu contribution for later rendering. + + ``data`` is accepted for the generic context contract. Menu + contributions already contain their callbacks, so this context does not + need to inspect app data directly. + """ + self.contributions.append((plugin_name, contribution)) + + def render(self): + """Render all collected menu contributions into registered locations.""" + grouped = {} + group_orders = {} + + for plugin_name, contribution in self.contributions: + if not isinstance(contribution, dict): + self.logger.error( + "Menu contribution from plugin '%s' is not a dictionary. " + "Skipping." % plugin_name + ) + continue + if 'location' not in contribution: + self.logger.error( + "Menu contribution from plugin '%s' missing location. " + "Skipping." % plugin_name + ) + continue + if 'name' not in contribution: + self.logger.error( + "Menu contribution from plugin '%s' missing name. " + "Skipping." % plugin_name + ) + continue + + location = contribution['location'] + if location not in self.locations: + self.logger.error( + "Menu contribution from plugin '%s' requested unknown " + "location '%s'. Skipping." % (plugin_name, location) + ) + continue + + path = contribution.get('path', ()) + if isinstance(path, str): + path = (path,) + else: + path = tuple(path) + + key = (location, path) + group = contribution.get('group', None) + if key not in group_orders: + group_orders[key] = {} + if group not in group_orders[key]: + group_orders[key][group] = len(group_orders[key]) + + grouped.setdefault(key, []).append((plugin_name, contribution)) + + menus = {} + for name, menu in self.locations.items(): + menus[(name, ())] = menu + + for key in sorted(grouped): + location, path = key + parent_path = () + for submenu_name in path: + submenu_path = parent_path + (submenu_name,) + submenu_key = (location, submenu_path) + if submenu_key not in menus: + menus[submenu_key] = menus[(location, parent_path)].addMenu( + submenu_name + ) + parent_path = submenu_path + menu = menus[(location, path)] + + contributions = sorted( + grouped[key], + key=lambda item: ( + group_orders[key][item[1].get('group', None)], + item[1].get('order', DEFAULT_PRIORITY), + item[0], + item[1]['name'], + ), + ) + + previous_group = None + for index, (plugin_name, contribution) in enumerate(contributions): + group = contribution.get('group', None) + if index and group != previous_group: + menu.addSeparator() + previous_group = group + + name = contribution['name'] + icon = contribution.get('icon', None) + if icon is not None and self.icon_factory is not None: + action = menu.addAction(self.icon_factory(icon), name) + else: + action = menu.addAction(name) + + callback = contribution.get('action', None) + if callback is not None: + action.triggered.connect(callback) + + shortcut = contribution.get('shortcut', None) + if shortcut is not None and hasattr(action, 'setShortcut'): + action.setShortcut(shortcut) + + checkable = contribution.get('checkable', False) + if hasattr(action, 'setCheckable'): + action.setCheckable(checkable) + + enabled = contribution.get('enabled', True) + if hasattr(action, 'setEnabled'): + action.setEnabled(enabled) + + +class PluginManager(object): + """Manage plugin discovery, lifecycle, and callback lookup. + + The manager keeps two internal mappings: + + * ``modules``: imported plugin modules keyed by plugin name + * ``plugins``: instantiated plugin objects keyed by plugin name + + An application normally creates one manager per plugin directory and uses + it to discover modules, instantiate plugin objects, build UI pieces, and + close plugins on shutdown. + """ + def __init__( + self, + plugin_package, + plugins_dir, + config, + config_section, + default_plugins=(), + logger=None, + ): + """Create a plugin manager. + + Args: + plugin_package (str): Import package containing plugin subpackages, + for example ``'myapp.plugins'``. + plugins_dir (str): Filesystem path scanned for plugin directories. + config: Config object with ``has_section()``, ``add_section()``, + ``items()``, ``set()``, and ``getboolean()`` methods. + config_section (str): Section containing plugin enable/disable + options. + default_plugins (iterable): Plugin names enabled by default when + first discovered. + logger (logging.Logger, optional): Logger used for plugin import, + setup, callback, and shutdown errors. + """ + self.plugin_package = plugin_package + self.plugins_dir = plugins_dir + self.config = config + self.config_section = config_section + self.default_plugins = set(default_plugins) + self.logger = logger or logging.getLogger(__name__) + self.modules = {} + self.plugins = {} + self.services = {} + self.contexts = {} + self._logged_plugin_warnings = set() + + def discover_modules(self): + """Scan the plugin directory, update config defaults, and import enabled modules.""" + if not self.config.has_section(self.config_section): + self.config.add_section(self.config_section) + + configured_plugins = set( + name for name, val in self.config.items(self.config_section) + ) + + modules = {} + for module_name in os.listdir(self.plugins_dir): + module_path = os.path.join(self.plugins_dir, module_name) + if not os.path.isdir(module_path) or module_name == '__pycache__': + continue + + # Keep the config in sync with what is present on disk. + if module_name not in configured_plugins: + self.config.set( + self.config_section, + module_name, + str(module_name in self.default_plugins), + ) + configured_plugins.add(module_name) + + # Only load activated plugins. + if self.config.getboolean(self.config_section, module_name): + try: + module = importlib.import_module( + self.plugin_package + '.' + module_name + ) + except Exception: + self.logger.exception( + "Could not import plugin '%s'. Skipping." % module_name + ) + else: + modules[module_name] = module + + self.modules = modules + return modules + + def instantiate_plugins(self, plugin_settings=None): + """Create ``Plugin`` instances for each imported module. + + ``plugin_settings`` should map plugin names to the saved configuration + dictionary for that plugin. Missing entries fall back to an empty + dictionary. Instantiation failures are logged and skipped. + """ + if plugin_settings is None: + plugin_settings = {} + + plugins = {} + for module_name, module in self.modules.items(): + try: + plugins[module_name] = module.Plugin( + plugin_settings[module_name] + if module_name in plugin_settings else {} + ) + except Exception: + self.logger.exception( + "Could not instantiate plugin '%s'. Skipping" % module_name + ) + + self.plugins = plugins + return plugins + + def setup_plugins(self, data, notifications, menu_builder, menubar): + """Collect plugin UI and startup contributions. + + This is typically called after the application has created its legacy + menu and notification hosts and before it finishes wiring its settings + UI and callback registrations. The method returns a tuple of + ``(settings_pages, settings_callbacks)`` for the application to + consume. + """ + settings_pages = [] + settings_callbacks = [] + + for module_name, plugin in self.plugins.items(): + try: + # Setup settings page. + settings_pages.extend(plugin.get_setting_classes()) + + # Setup menu. + menu_class = plugin.get_menu_class() + if menu_class: + _log_once( + self.logger, + self._logged_plugin_warnings, + logging.WARNING, + (module_name, 'get_menu_class'), + "Plugin '%s' uses deprecated get_menu_class(); " + "prefer get_menu_contributions() instead." % module_name + ) + # Must store a reference or else the methods called when + # the menu actions are triggered will be garbage collected. + menu = menu_class(data) + menu_builder.create_menu(menubar, menu.get_menu_items()) + plugin.set_menu_instance(menu) + + # Setup notifications. + plugin_notifications = {} + for notification_class in plugin.get_notification_classes(): + notifications.add_notification(notification_class) + plugin_notifications[notification_class] = ( + notifications.get_instance(notification_class) + ) + plugin.set_notification_instances(plugin_notifications) + + # Register callbacks. + callbacks = self._get_plugin_event_handlers(module_name, plugin) + # Save the settings_changed callback in a separate list for + # setting up later. + if callbacks and 'settings_changed' in callbacks: + settings_callbacks.append(callbacks['settings_changed']) + + except Exception: + self.logger.exception( + "Plugin '%s' error. Plugin may not be functional." % module_name + ) + + return settings_pages, settings_callbacks + + def register_context(self, name, context): + """Register an application-owned plugin contribution context.""" + self.contexts[name] = context + + def _get_contributions(self, plugin, module_name, method_name, label): + """Return a plugin contribution iterable, logging malformed results.""" + if not hasattr(plugin, method_name): + return [] + + try: + contributions = getattr(plugin, method_name)() + except Exception: + self.logger.exception( + "Error getting %s contributions from plugin '%s'. Skipping." + % (label, module_name) + ) + return [] + + if contributions is None: + return [] + + if ( + isinstance(contributions, (dict, str, bytes)) + or not hasattr(contributions, '__iter__') + ): + self.logger.error( + "Plugin '%s' %s contributions must be an iterable of " + "dictionaries. Skipping." % (module_name, label) + ) + return [] + + return contributions + + def setup_contexts(self, data): + """Route plugin-declared UI and menu contributions to app contexts.""" + for module_name, plugin in self.plugins.items(): + ui_contributions = self._get_contributions( + plugin, + module_name, + 'get_ui_contributions', + 'UI', + ) + + for contribution in ui_contributions: + if not isinstance(contribution, dict): + self.logger.error( + "UI contribution from plugin '%s' is not a dictionary. " + "Skipping." % module_name + ) + continue + if 'context' not in contribution: + self.logger.error( + "UI contribution from plugin '%s' missing context. " + "Skipping." % module_name + ) + continue + + context_name = contribution['context'] + if context_name not in self.contexts: + self.logger.error( + "UI contribution from plugin '%s' requested unknown " + "context '%s'. Skipping." % (module_name, context_name) + ) + continue + + try: + self.contexts[context_name].add(module_name, contribution, data) + except Exception: + self.logger.exception( + "Error adding UI contribution from plugin '%s' to " + "context '%s'. Skipping." % (module_name, context_name) + ) + + menu_contributions = self._get_contributions( + plugin, + module_name, + 'get_menu_contributions', + 'menu', + ) + + if menu_contributions and 'menus' not in self.contexts: + self.logger.error( + "Plugin '%s' provided menu contributions, but no 'menus' " + "context is registered. Skipping." % module_name + ) + continue + + for contribution in menu_contributions: + if not isinstance(contribution, dict): + self.logger.error( + "Menu contribution from plugin '%s' is not a " + "dictionary. Skipping." % module_name + ) + continue + try: + self.contexts['menus'].add(module_name, contribution, data) + except Exception: + self.logger.exception( + "Error adding menu contribution from plugin '%s'. " + "Skipping." % module_name + ) + + def collect_services(self, base_services=None): + """Merge app-owned and plugin-owned services into one registry.""" + if base_services is None: + services = {} + elif isinstance(base_services, Mapping): + services = dict(base_services) + else: + self.logger.error( + "Base services must be a mapping. Ignoring invalid registry." + ) + services = {} + + for module_name, plugin in self.plugins.items(): + if not hasattr(plugin, "get_services"): + continue + + try: + plugin_services = plugin.get_services() + except Exception: + self.logger.exception( + "Error getting services from plugin '%s'. Skipping." + % module_name + ) + continue + + if plugin_services is None: + continue + if not isinstance(plugin_services, Mapping): + self.logger.error( + "Plugin '%s' services must be a mapping. Skipping." + % module_name + ) + continue + + for service_name, service in plugin_services.items(): + if service_name in services: + self.logger.error( + "Plugin '%s' service '%s' conflicts with an existing " + "service. Keeping the original registration." + % (module_name, service_name) + ) + continue + services[service_name] = service + + self.services = services + return services + + def setup_complete(self, data): + """Run plugin setup activities after application startup.""" + setup_activities = [] + + for module_name, plugin in self.plugins.items(): + if hasattr(plugin, 'get_setup_activities'): + try: + plugin_activities = plugin.get_setup_activities() + except Exception: + self.logger.exception( + "Error getting setup activities from plugin '%s'. " + "Skipping." % module_name + ) + continue + elif hasattr(plugin, 'plugin_setup_complete'): + plugin_activities = [ + { + 'name': 'plugin_setup_complete', + 'priority': DEFAULT_SETUP_PRIORITY, + 'action': plugin.plugin_setup_complete, + }, + ] + else: + continue + + if plugin_activities is None: + continue + if ( + isinstance(plugin_activities, (dict, str, bytes)) + or not hasattr(plugin_activities, '__iter__') + ): + self.logger.error( + "Plugin '%s' setup activities must be an iterable of " + "dictionaries. Skipping." % module_name + ) + continue + + for activity in plugin_activities: + if not isinstance(activity, Mapping): + self.logger.error( + "Setup activity from plugin '%s' is not a dictionary. " + "Skipping." % module_name + ) + continue + + try: + name = activity['name'] + priority = activity['priority'] + action = activity['action'] + except KeyError as exc: + self.logger.error( + "Setup activity from plugin '%s' missing key '%s'. " + "Skipping." % (module_name, exc.args[0]) + ) + continue + + if not callable(action): + self.logger.error( + "Setup activity '%s' from plugin '%s' action is not " + "callable. Skipping." % (name, module_name) + ) + continue + + setup_activities.append( + { + 'module_name': module_name, + 'plugin': plugin, + 'name': name, + 'priority': priority, + 'action': action, + } + ) + + setup_activities.sort( + key=lambda activity: ( + activity['priority'], + activity['module_name'], + activity['name'], + ) + ) + + for activity in setup_activities: + module_name = activity['module_name'] + activity_name = activity['name'] + action = activity['action'] + try: + action(data) + except Exception: + if activity_name != 'plugin_setup_complete': + self.logger.exception( + "Error in setup activity '%s' for plugin '%s'. " + "Plugin may not be functional." + % (activity_name, module_name) + ) + continue + + self.logger.exception( + "Error in plugin_setup_complete() for plugin '%s'. " + "Trying again with old call signature..." % module_name + ) + try: + action() + self.logger.warning( + "Plugin '%s' using old API. Please update " + "Plugin.plugin_setup_complete method to accept a " + "dictionary of blacs_data as the only argument." + % module_name + ) + except Exception: + self.logger.exception( + "Plugin '%s' error. Plugin may not be functional." + % module_name + ) + + def _defines_hook(self, plugin, method_name): + """Return whether ``plugin`` overrides ``method_name`` meaningfully.""" + plugin_method = getattr(type(plugin), method_name, None) + if plugin_method is None: + return False + + base_method = getattr(BasePlugin, method_name, None) + return plugin_method is not base_method + + def _get_plugin_event_handlers(self, module_name, plugin): + """Return normalized event handlers for one plugin.""" + has_modern = self._defines_hook(plugin, 'get_event_handlers') + has_legacy = self._defines_hook(plugin, 'get_callbacks') + + if has_modern: + if has_legacy: + _log_once( + self.logger, + self._logged_plugin_warnings, + logging.WARNING, + (module_name, 'get_callbacks'), + "Plugin '%s' defines deprecated get_callbacks() and " + "preferred get_event_handlers(); ignoring get_callbacks()." + % module_name + ) + handlers = plugin.get_event_handlers() + elif has_legacy: + _log_once( + self.logger, + self._logged_plugin_warnings, + logging.WARNING, + (module_name, 'get_callbacks'), + "Plugin '%s' uses deprecated get_callbacks(); " + "implement get_event_handlers() instead." % module_name + ) + handlers = plugin.get_callbacks() + else: + return None + + if handlers is None: + return None + if not isinstance(handlers, Mapping): + self.logger.error( + "Plugin '%s' event handlers must be a mapping. Skipping." + % module_name + ) + return None + + return handlers + + def get_event_handlers(self, name): + """Return all event handlers registered for ``name``, sorted by priority.""" + callbacks = [] + + for module_name, plugin in self.plugins.items(): + try: + plugin_callbacks = self._get_plugin_event_handlers( + module_name, + plugin, + ) + if plugin_callbacks and name in plugin_callbacks: + callbacks.append(plugin_callbacks[name]) + except Exception: + self.logger.exception('Error getting callbacks from %s.' % str(plugin)) + + callbacks.sort( + key=lambda callback: getattr(callback, 'priority', DEFAULT_PRIORITY) + ) + return callbacks + + def get_callbacks(self, name): + """Deprecated compatibility wrapper for :meth:`get_event_handlers`.""" + warnings.warn( + "PluginManager.get_callbacks() is deprecated; use " + "PluginManager.get_event_handlers() instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.get_event_handlers(name) + + def close_plugins(self): + """Call ``close()`` on every plugin during application shutdown.""" + for module_name, plugin in self.plugins.items(): + try: + plugin.close() + except Exception as e: + self.logger.error( + 'Could not close plugin %s. Error was: %s' + % (module_name, str(e)) + ) diff --git a/labscript_utils/properties.py b/labscript_utils/properties.py index 092df528..5f8488ed 100644 --- a/labscript_utils/properties.py +++ b/labscript_utils/properties.py @@ -61,6 +61,10 @@ def _default(o): # Workaround for https://bugs.python.org/issue24313 if isinstance(o, np.integer): return int(o) + if isinstance(o, np.floating): + return float(o) + if isinstance(o, np.bool_): + return bool(o) raise TypeError diff --git a/labscript_utils/qtwidgets/__init__.py b/labscript_utils/qtwidgets/__init__.py index af02b0ad..3f90f1c0 100644 --- a/labscript_utils/qtwidgets/__init__.py +++ b/labscript_utils/qtwidgets/__init__.py @@ -9,4 +9,25 @@ # BSD License. See the license.txt file in the root of the project # # for the full license. # # # -##################################################################### \ No newline at end of file +##################################################################### + +__all__ = ['FILEPATH_COLUMN', 'ShotQueueTreeView', 'ShotQueueWidget'] + + +def __getattr__(name): + if name not in __all__: + raise AttributeError("module %r has no attribute %r" % (__name__, name)) + + from .shotqueue import FILEPATH_COLUMN, ShotQueueTreeView, ShotQueueWidget + + exports = { + 'FILEPATH_COLUMN': FILEPATH_COLUMN, + 'ShotQueueTreeView': ShotQueueTreeView, + 'ShotQueueWidget': ShotQueueWidget, + } + globals().update(exports) + return exports[name] + + +def __dir__(): + return sorted(list(globals().keys()) + __all__) diff --git a/labscript_utils/qtwidgets/shotqueue.py b/labscript_utils/qtwidgets/shotqueue.py new file mode 100644 index 00000000..239dbfa1 --- /dev/null +++ b/labscript_utils/qtwidgets/shotqueue.py @@ -0,0 +1,387 @@ +##################################################################### +# # +# shotqueue.py # +# # +# Copyright 2026, Monash University # +# # +# This file is part of the labscript suite (see # +# http://labscriptsuite.org) and is licensed under the Simplified # +# BSD License. See the license.txt file in the root of the project # +# for the full license. # +# # +##################################################################### +import os + +from qtutils.qt.QtCore import * +from qtutils.qt.QtGui import * +from qtutils.qt.QtWidgets import * +from qtutils.qt.QtCore import pyqtSignal as Signal + + +FILEPATH_COLUMN = 0 +__all__ = ['FILEPATH_COLUMN', 'ShotQueueTreeView', 'ShotQueueWidget'] + + +def _normalise_extensions(accepted_extensions): + if accepted_extensions is None: + accepted_extensions = ('.h5', '.hdf5') + elif isinstance(accepted_extensions, str): + accepted_extensions = (accepted_extensions,) + return tuple(extension.lower() for extension in accepted_extensions) + + +class ShotQueueTreeView(QTreeView): + """Generic queue view with delete-key handling and shot-file drops.""" + + deleteRequested = Signal() + filesDropped = Signal(list) + + def __init__(self, parent=None, accepted_extensions=None): + QTreeView.__init__(self, parent) + self._accepted_extensions = _normalise_extensions(accepted_extensions) + self.header().setStretchLastSection(True) + self.setAutoScroll(False) + self.setAcceptDrops(True) + self.setDragEnabled(True) + self.setDropIndicatorShown(True) + self.setDragDropMode(QAbstractItemView.DragDrop) + self.setDefaultDropAction(Qt.MoveAction) + self.setSelectionBehavior(QAbstractItemView.SelectRows) + self.setSelectionMode(QAbstractItemView.ExtendedSelection) + self.setRootIsDecorated(False) + self.setUniformRowHeights(True) + + def accepted_extensions(self): + return tuple(self._accepted_extensions) + + def set_accepted_extensions(self, accepted_extensions): + self._accepted_extensions = _normalise_extensions(accepted_extensions) + + def keyPressEvent(self, event): + if event.key() in (Qt.Key_Delete, Qt.Key_Backspace): + event.accept() + self.deleteRequested.emit() + return + QTreeView.keyPressEvent(self, event) + + def contextMenuEvent(self, event): + menu = QMenu(self) + delete_action = menu.addAction('Delete selected rows') + delete_action.setEnabled(bool(self.selectionModel().selectedRows())) + chosen_action = menu.exec_(event.globalPos()) + if chosen_action is delete_action: + self.deleteRequested.emit() + event.accept() + return + QTreeView.contextMenuEvent(self, event) + + def dragEnterEvent(self, event): + if self._event_has_acceptable_urls(event): + event.setDropAction(Qt.CopyAction) + event.accept() + return + QTreeView.dragEnterEvent(self, event) + + def dragMoveEvent(self, event): + if self._event_has_acceptable_urls(event): + event.setDropAction(Qt.CopyAction) + event.accept() + return + QTreeView.dragMoveEvent(self, event) + + def dropEvent(self, event): + if self._event_has_acceptable_urls(event): + paths = self._paths_from_urls(event.mimeData().urls()) + if paths: + event.setDropAction(Qt.CopyAction) + event.accept() + self.filesDropped.emit(paths) + return + QTreeView.dropEvent(self, event) + + def _event_has_acceptable_urls(self, event): + if not event.mimeData().hasUrls(): + return False + return bool(self._paths_from_urls(event.mimeData().urls())) + + def _paths_from_urls(self, urls): + paths = [] + for url in urls: + if not url.isLocalFile(): + continue + path = os.path.abspath(str(url.toLocalFile())) + if self._is_accepted_path(path): + paths.append(path) + return paths + + def _is_accepted_path(self, path): + return os.path.isfile(path) and path.lower().endswith(self._accepted_extensions) + +class ShotQueueWidget(QWidget): + """Reusable shot queue editor widget.""" + + queueChanged = Signal() + selectionChanged = Signal(list) + filesAdded = Signal(list) + + def __init__( + self, + parent=None, + accepted_extensions=None, + file_dialog_filter='Shot files (*.h5 *.hdf5)', + allow_duplicates=False, + column_title='Filepath', + column_titles=None, + path_column=FILEPATH_COLUMN, + connect_add_button=True, + connect_delete_requested=True, + connect_files_dropped=True, + ): + QWidget.__init__(self, parent) + self.accepted_extensions = _normalise_extensions(accepted_extensions) + self.file_dialog_filter = file_dialog_filter + self.allow_duplicates = allow_duplicates + self.last_opened_shots_folder = '' + if column_titles is None: + column_titles = [column_title] + self.column_titles = list(column_titles) + self.path_column = int(path_column) + + self.queue_model = QStandardItemModel(self) + self.queue_model.setColumnCount(len(self.column_titles)) + for column, title in enumerate(self.column_titles): + self.queue_model.setHorizontalHeaderItem(column, QStandardItem(title)) + + self.queue_view = ShotQueueTreeView(self, accepted_extensions=self.accepted_extensions) + self.queue_view.setModel(self.queue_model) + self.queue_view.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn) + self.queue_view.header().setStretchLastSection(False) + for column in range(len(self.column_titles)): + resize_mode = ( + QHeaderView.Stretch + if column == self.path_column + else QHeaderView.ResizeToContents + ) + self.queue_view.header().setSectionResizeMode(column, resize_mode) + + self.add_button = QToolButton(self) + self.add_button.setText('Add') + + button_layout = QHBoxLayout() + button_layout.setContentsMargins(0, 0, 0, 0) + button_layout.addWidget(self.add_button) + button_layout.addStretch(1) + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.queue_view) + layout.addLayout(button_layout) + + if connect_add_button: + self.add_button.clicked.connect(self.prompt_for_files) + if connect_delete_requested: + self.queue_view.deleteRequested.connect(self.remove_selected) + if connect_files_dropped: + self.queue_view.filesDropped.connect(self.add_files) + self.queue_model.rowsInserted.connect(self._on_queue_changed) + self.queue_model.rowsRemoved.connect(self._on_queue_changed) + self.queue_model.modelReset.connect(self._on_queue_changed) + self.queue_view.selectionModel().selectionChanged.connect(self._on_selection_changed) + + self._update_button_states() + + def files(self): + return [ + self._queue_path_for_row(row) + for row in range(self.queue_model.rowCount()) + ] + + def selected_files(self): + return [self._queue_path_for_row(row) for row in self.selected_rows()] + + def selected_rows(self): + return sorted(index.row() for index in self.queue_view.selectionModel().selectedRows()) + + def select_paths(self, paths): + if isinstance(paths, str): + paths = [paths] + path_lookup = {os.path.abspath(str(path)) for path in paths} + rows = [] + for row in range(self.queue_model.rowCount()): + if self._queue_path_for_row(row) in path_lookup: + rows.append(row) + self._select_rows(rows) + + def set_files(self, paths): + self.queue_model.removeRows(0, self.queue_model.rowCount()) + self.append(paths) + + def append(self, paths): + paths = self._prepare_paths(paths) + if not paths: + return [] + for path in paths: + self.queue_model.appendRow(self._create_row(path)) + self.filesAdded.emit(paths) + return paths + + def prepend(self, path): + paths = self._prepare_paths([path]) + if not paths: + return [] + self.queue_model.insertRow(0, self._create_row(paths[0])) + self.filesAdded.emit(paths) + self._select_rows([0]) + return paths + + def add_files(self, paths): + added = self.append(paths) + if added: + self.last_opened_shots_folder = os.path.dirname(added[-1]) + first_new_row = self.queue_model.rowCount() - len(added) + self._select_rows(range(first_new_row, self.queue_model.rowCount())) + return added + + def prompt_for_files(self): + file_names = QFileDialog.getOpenFileNames( + self, + 'Select shot file(s) to add to the queue', + self.last_opened_shots_folder, + self.file_dialog_filter, + ) + if isinstance(file_names, tuple): + file_names, _ = file_names + return self.add_files(file_names) + + def remove_selected(self): + for row in reversed(self.selected_rows()): + self.queue_model.removeRow(row) + + def clear(self): + if self.queue_model.rowCount(): + self.queue_model.removeRows(0, self.queue_model.rowCount()) + + def is_in_queue(self, path): + path = os.path.abspath(str(path)) + return any( + self._queue_path_for_row(row) == path + for row in range(self.queue_model.rowCount()) + ) + + def get_save_data(self): + return { + 'files_queued': self.files(), + 'last_opened_shots_folder': self.last_opened_shots_folder, + } + + def restore_save_data(self, data): + self.last_opened_shots_folder = data.get('last_opened_shots_folder', '') + self.set_files(data.get('files_queued', [])) + + def set_row_infos(self, row_infos): + self.queue_model.removeRows(0, self.queue_model.rowCount()) + if not row_infos: + return + for row_info in row_infos: + self.queue_model.appendRow(self._create_row_from_info(row_info)) + + def _create_row(self, path): + row_items = self._create_padding_items(self.queue_model.columnCount()) + row_items[self.path_column] = self._create_display_item( + path, + tooltip=path, + path=path, + ) + return row_items + + def _create_row_from_info(self, row_info): + if not isinstance(row_info, dict): + return self._create_row(str(row_info)) + path = os.path.abspath(str(row_info['path'])) + label = row_info.get('label', os.path.basename(path)) + tooltip = row_info.get('tooltip', path) + columns = list(row_info.get('columns', [])) + row_items = self._create_padding_items(self.queue_model.columnCount()) + row_items[self.path_column] = self._create_display_item( + label, + tooltip=tooltip, + path=path, + ) + extra_columns = [i for i in range(self.queue_model.columnCount()) if i != self.path_column] + for column_index, column_info in zip(extra_columns, columns): + row_items[column_index] = self._create_display_item_from_info(column_info) + return row_items + + def _create_padding_items(self, count): + items = [] + for _ in range(max(0, count)): + items.append(self._create_display_item('')) + return items + + def _create_display_item(self, text, tooltip='', alignment=None, path=None): + item = QStandardItem(str(text)) + item.setEditable(False) + item.setToolTip(tooltip) + if alignment is not None: + item.setTextAlignment(alignment) + if path is not None: + item.setData(os.path.abspath(str(path)), Qt.UserRole) + return item + + def _create_display_item_from_info(self, column_info): + if isinstance(column_info, dict): + text = column_info.get('text', '') + tooltip = column_info.get('tooltip', '') + alignment = column_info.get('alignment', Qt.AlignLeft | Qt.AlignVCenter) + else: + text = column_info + tooltip = '' + alignment = Qt.AlignLeft | Qt.AlignVCenter + return self._create_display_item(text, tooltip=tooltip, alignment=alignment) + + def _queue_path_for_row(self, row): + item = self.queue_model.item(row, self.path_column) + path = item.data(Qt.UserRole) + if path is None: + path = item.text() + return os.path.abspath(str(path)) + + def _prepare_paths(self, paths): + if isinstance(paths, str): + paths = [paths] + existing = set(self.files()) + pending = set() + prepared = [] + for path in paths: + path = os.path.abspath(str(path)) + if not self._is_accepted_path(path): + continue + if not self.allow_duplicates and (path in existing or path in pending): + continue + pending.add(path) + prepared.append(path) + return prepared + + def _is_accepted_path(self, path): + return os.path.isfile(path) and path.lower().endswith(self.accepted_extensions) + + def _on_queue_changed(self, *args): + self._update_button_states() + self.queueChanged.emit() + + def _on_selection_changed(self, *args): + self._update_button_states() + self.selectionChanged.emit(self.selected_files()) + + def _update_button_states(self): + pass + + def _select_rows(self, rows): + rows = list(rows) + selection_model = self.queue_view.selectionModel() + selection_model.clearSelection() + for row in rows: + index = self.queue_model.index(row, self.path_column) + selection_model.select(index, QItemSelectionModel.Select | QItemSelectionModel.Rows) + if rows: + self.queue_view.setCurrentIndex(self.queue_model.index(rows[0], self.path_column)) diff --git a/labscript_utils/shot_utils.py b/labscript_utils/shot_utils.py index aaccced9..987abed7 100644 --- a/labscript_utils/shot_utils.py +++ b/labscript_utils/shot_utils.py @@ -14,6 +14,7 @@ import h5py import numpy as np + def get_shot_globals(filepath): """Returns the evaluated globals for a shot, for use by labscript or lyse. Simple dictionary access as in dict(h5py.File(filepath).attrs) would be fine @@ -34,4 +35,4 @@ def get_shot_globals(filepath): if isinstance(value, bytes): value = value.decode() params[name] = value - return params \ No newline at end of file + return params diff --git a/labscript_utils/splash.py b/labscript_utils/splash.py index 75cedf59..46922905 100644 --- a/labscript_utils/splash.py +++ b/labscript_utils/splash.py @@ -39,6 +39,47 @@ QtWidgets.QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True) +def configure_qapplication(qapplication): + """Apply labscript-wide QApplication configuration.""" + qapplication.setAttribute(Qt.AA_DontShowIconsInMenus, False) + if sys.platform == 'darwin': + icon_path = qapplication.property('_labscript_icon_path') + if icon_path: + icon = QtGui.QIcon(icon_path) + if not icon.isNull(): + qapplication.setWindowIcon(icon) + if qapplication.property('_labscript_qapplication_configured'): + return qapplication + # Native macOS widget styling makes many Qt controls look inconsistent + # with the rest of the suite. Use Qt's own style, but preserve the + # current palette so dark/light appearance still follows the active + # theme. + palette = QtGui.QPalette(qapplication.palette()) + style = QtWidgets.QStyleFactory.create('Fusion') + if style is not None: + qapplication.setStyle(style) + qapplication.setPalette(palette) + elif qapplication.property('_labscript_qapplication_configured'): + return qapplication + qapplication.setProperty('_labscript_qapplication_configured', True) + return qapplication + +def get_qapplication(argv=None, application_name=None, icon_path=None): + qapplication = QtWidgets.QApplication.instance() + + if qapplication is None: + argv = sys.argv if argv is None else argv + if application_name is not None: + # Create a new argv so QApplication can alter it without mutating sys.argv. + argv = [application_name] + argv[1:] + + qapplication = QtWidgets.QApplication(argv) + + if icon_path is not None: + qapplication.setProperty('_labscript_icon_path', icon_path) + return configure_qapplication(qapplication) + + class Splash(QtWidgets.QFrame): w = 250 h = 230 @@ -49,15 +90,15 @@ class Splash(QtWidgets.QFrame): BG = '#ffffff' FG = '#000000' - def __init__(self, imagepath): - self.qapplication = QtWidgets.QApplication.instance() - if self.qapplication is None: - self.qapplication = QtWidgets.QApplication(sys.argv) + def __init__(self, icon_path, application_name=None): + self.qapplication = get_qapplication( + application_name=application_name, icon_path=icon_path + ) super().__init__() self.icon = QtGui.QPixmap() - self.icon.load(imagepath) + self.icon.load(icon_path) if self.icon.isNull(): - raise ValueError("Invalid image file: {}.\n".format(imagepath)) + raise ValueError("Invalid image file: {}.\n".format(icon_path)) self.icon = self.icon.scaled( self.imwidth, self.imheight, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation ) diff --git a/labscript_utils/zlock.py b/labscript_utils/zlock.py index 5878997f..baac341b 100644 --- a/labscript_utils/zlock.py +++ b/labscript_utils/zlock.py @@ -40,7 +40,7 @@ def main(): '-m', 'zprocess.zlock', '--port', - config['zlock_port'], + str(config['zlock_port']), '-l', LOG_PATH, ] diff --git a/pyproject.toml b/pyproject.toml index 53ec1e59..fed85e39 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,8 @@ dependencies = [ "scipy", "zprocess>=2.18.0", "setuptools_scm>=4.1.0", + "tomli>=1.1.0; python_version < '3.11'", + "tomli-w>=1.2.0", ] dynamic = ["version"] diff --git a/tests/test_plugins.py b/tests/test_plugins.py new file mode 100644 index 00000000..6cea9928 --- /dev/null +++ b/tests/test_plugins.py @@ -0,0 +1,871 @@ +import logging +import sys +import uuid +import warnings +from pathlib import Path +from types import SimpleNamespace + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from labscript_utils.plugins import ( + BasePlugin, + Callback, + DEFAULT_SETUP_PRIORITY, + MenuBuilder, + MenuContext, + PluginManager, + callback, +) + + +class FakeConfig(object): + def __init__(self): + self.sections = {} + + def has_section(self, name): + return name in self.sections + + def add_section(self, name): + self.sections[name] = {} + + def items(self, name): + return list(self.sections[name].items()) + + def set(self, section, option, value): + self.sections[section][option] = value + + def getboolean(self, section, option): + return self.sections[section][option].lower() == 'true' + + +def test_callback_binds_as_method_and_keeps_priority(): + class Example(object): + @callback(priority=5) + def method(self, value): + return self, value + + example = Example() + + assert isinstance(Example.__dict__['method'], Callback) + assert Example.__dict__['method'].priority == 5 + assert example.method('value') == (example, 'value') + + +def test_base_plugin_defaults_are_no_ops(): + plugin = BasePlugin({'x': 1}) + + assert plugin.initial_settings == {'x': 1} + assert plugin.get_menu_class() is None + assert plugin.get_notification_classes() == [] + assert plugin.get_setting_classes() == [] + assert plugin.get_event_handlers() is None + assert plugin.get_callbacks() is None + assert plugin.get_ui_contributions() == [] + assert plugin.get_menu_contributions() == [] + assert plugin.get_save_data() == {} + + +def make_plugin_package(tmp_path, modules): + package = 'plugin_test_' + uuid.uuid4().hex + package_dir = tmp_path / package + plugins_dir = package_dir / 'plugins' + plugins_dir.mkdir(parents=True) + (package_dir / '__init__.py').write_text('') + (plugins_dir / '__init__.py').write_text('') + for name, source in modules.items(): + module_dir = plugins_dir / name + module_dir.mkdir() + (module_dir / '__init__.py').write_text(source) + sys.path.insert(0, str(tmp_path)) + return package + '.plugins', plugins_dir + + +def make_manager(logger=None): + return PluginManager( + 'package.plugins', + 'plugins', + FakeConfig(), + 'app/plugins', + logger=logger, + ) + + +class NoNotifications(object): + def add_notification(self, notification_class): + raise AssertionError('no notifications expected') + + def get_instance(self, notification_class): + raise AssertionError('no notifications expected') + + +def test_discovery_defaults_config_and_imports_only_enabled_plugins(tmp_path): + plugin_package, plugins_dir = make_plugin_package( + tmp_path, + { + 'enabled': 'class Plugin(object):\n pass\n', + 'disabled': 'raise RuntimeError("should not import")\n', + }, + ) + config = FakeConfig() + manager = PluginManager( + plugin_package, + str(plugins_dir), + config, + 'app/plugins', + ['enabled'], + ) + + modules = manager.discover_modules() + + assert set(modules) == {'enabled'} + assert config.sections['app/plugins']['enabled'] == 'True' + assert config.sections['app/plugins']['disabled'] == 'False' + + +def test_instantiate_plugins_uses_saved_settings(): + class Plugin(object): + def __init__(self, initial_settings): + self.initial_settings = initial_settings + + manager = PluginManager( + 'package.plugins', + 'plugins', + FakeConfig(), + 'app/plugins', + ) + manager.modules = {'plugin': SimpleNamespace(Plugin=Plugin)} + + plugins = manager.instantiate_plugins({'plugin': {'answer': 42}}) + + assert plugins['plugin'].initial_settings == {'answer': 42} + + +def test_get_event_handlers_sorts_legacy_callbacks_by_priority_and_logs_errors(caplog): + class Slow(object): + def get_callbacks(self): + return {'event': self.on_event} + + @callback(priority=20) + def on_event(self): + pass + + class Fast(object): + def get_callbacks(self): + return {'event': self.on_event} + + @callback(priority=5) + def on_event(self): + pass + + class Broken(object): + def get_callbacks(self): + raise RuntimeError('broken') + + logger = logging.getLogger('test.plugins') + manager = make_manager(logger=logger) + manager.plugins = { + 'slow': Slow(), + 'broken': Broken(), + 'fast': Fast(), + } + + with caplog.at_level(logging.ERROR, logger='test.plugins'): + callbacks = manager.get_event_handlers('event') + + assert [callback.priority for callback in callbacks] == [5, 20] + assert 'Error getting callbacks from' in caplog.text + + +def test_get_event_handlers_sorts_modern_handlers_by_priority(): + class Slow(object): + def get_event_handlers(self): + return {'event': self.on_event} + + @callback(priority=20) + def on_event(self): + pass + + class Fast(object): + def get_event_handlers(self): + return {'event': self.on_event} + + @callback(priority=5) + def on_event(self): + pass + + manager = make_manager() + manager.plugins = { + 'slow': Slow(), + 'fast': Fast(), + } + + callbacks = manager.get_event_handlers('event') + + assert [callback.priority for callback in callbacks] == [5, 20] + + +def test_get_callbacks_warns_and_delegates_to_get_event_handlers(): + class Plugin(object): + def get_event_handlers(self): + return {'event': self.on_event} + + @callback(priority=5) + def on_event(self): + pass + + manager = make_manager() + manager.plugins = {'plugin': Plugin()} + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter('always', DeprecationWarning) + callbacks = manager.get_callbacks('event') + + assert [callback.priority for callback in callbacks] == [5] + assert len(caught) == 1 + assert 'PluginManager.get_callbacks() is deprecated' in str(caught[0].message) + + +def test_get_event_handlers_prefers_modern_hook_and_warns_for_legacy_one(caplog): + class ModernAndLegacy(object): + def get_event_handlers(self): + return {'event': self.new_event} + + def get_callbacks(self): + return {'event': self.old_event} + + @callback(priority=5) + def new_event(self): + pass + + @callback(priority=20) + def old_event(self): + pass + + class LegacyOnly(object): + def get_callbacks(self): + return {'event': self.old_event} + + @callback(priority=10) + def old_event(self): + pass + + logger = logging.getLogger('test.plugins') + manager = make_manager(logger=logger) + manager.plugins = { + 'modern_and_legacy': ModernAndLegacy(), + 'legacy_only': LegacyOnly(), + } + + with caplog.at_level(logging.WARNING, logger='test.plugins'): + first_callbacks = manager.get_event_handlers('event') + second_callbacks = manager.get_event_handlers('event') + + assert [callback.priority for callback in first_callbacks] == [5, 10] + assert [callback.priority for callback in second_callbacks] == [5, 10] + warning_messages = [ + record.getMessage() + for record in caplog.records + if record.levelno == logging.WARNING + ] + assert sum('modern_and_legacy' in message for message in warning_messages) == 1 + assert sum('legacy_only' in message for message in warning_messages) == 1 + assert sum('get_callbacks()' in message for message in warning_messages) == 2 + + +def test_setup_plugins_prefers_modern_settings_callback_and_warns(caplog): + class Plugin(BasePlugin): + def get_event_handlers(self): + return {'settings_changed': self.new_settings_changed} + + def get_callbacks(self): + return {'settings_changed': self.old_settings_changed} + + @callback(priority=5) + def new_settings_changed(self): + pass + + @callback(priority=20) + def old_settings_changed(self): + pass + + logger = logging.getLogger('test.plugins') + manager = make_manager(logger=logger) + manager.plugins = {'plugin': Plugin({})} + + with caplog.at_level(logging.WARNING, logger='test.plugins'): + settings_pages, settings_callbacks = manager.setup_plugins( + data={}, + notifications=NoNotifications(), + menu_builder=MenuBuilder(), + menubar=FakeMenu(), + ) + callbacks = manager.get_event_handlers('settings_changed') + + assert settings_pages == [] + assert [callback.priority for callback in settings_callbacks] == [5] + assert [callback.priority for callback in callbacks] == [5] + warning_messages = [ + record.getMessage() + for record in caplog.records + if record.levelno == logging.WARNING + ] + assert sum('deprecated get_callbacks()' in message for message in warning_messages) == 1 + + +def test_setup_plugins_warns_when_deprecated_menu_class_is_used(caplog): + class Menu(object): + def __init__(self, data): + self.data = data + + def get_menu_items(self): + return {'name': 'Legacy'} + + class Plugin(BasePlugin): + def get_menu_class(self): + return Menu + + logger = logging.getLogger('test.plugins') + manager = make_manager(logger=logger) + manager.plugins = {'plugin': Plugin({})} + + with caplog.at_level(logging.WARNING, logger='test.plugins'): + manager.setup_plugins( + data={'example': True}, + notifications=NoNotifications(), + menu_builder=MenuBuilder(), + menubar=FakeMenu(), + ) + manager.setup_plugins( + data={'example': True}, + notifications=NoNotifications(), + menu_builder=MenuBuilder(), + menubar=FakeMenu(), + ) + + warning_messages = [ + record.getMessage() + for record in caplog.records + if record.levelno == logging.WARNING + ] + assert sum('deprecated get_menu_class()' in message for message in warning_messages) == 1 + + +def test_get_event_handlers_logs_and_skips_malformed_event_handler_mappings(caplog): + class BadModern(object): + def get_event_handlers(self): + return ['event'] + + class BadLegacy(object): + def get_callbacks(self): + return ['event'] + + logger = logging.getLogger('test.plugins') + manager = make_manager(logger=logger) + manager.plugins = { + 'bad_modern': BadModern(), + 'bad_legacy': BadLegacy(), + } + + with caplog.at_level(logging.ERROR, logger='test.plugins'): + callbacks = manager.get_event_handlers('event') + + assert callbacks == [] + assert "bad_modern" in caplog.text + assert "bad_legacy" in caplog.text + assert "event handlers must be a mapping" in caplog.text + + +class FakeSignal(object): + def __init__(self): + self.callbacks = [] + + def connect(self, callback): + self.callbacks.append(callback) + + +class FakeAction(object): + def __init__(self, name, icon=None): + self.name = name + self.icon = icon + self.triggered = FakeSignal() + self.shortcut = None + self.checkable = None + self.enabled = None + + def setShortcut(self, shortcut): + self.shortcut = shortcut + + def setCheckable(self, checkable): + self.checkable = checkable + + def setEnabled(self, enabled): + self.enabled = enabled + + +class FakeMenu(object): + def __init__(self, name='root'): + self.name = name + self.items = [] + + def addMenu(self, name): + menu = FakeMenu(name) + self.items.append(('menu', menu)) + return menu + + def addAction(self, *args): + if len(args) == 1: + action = FakeAction(args[0]) + else: + action = FakeAction(args[1], icon=args[0]) + self.items.append(('action', action)) + return action + + def addSeparator(self): + self.items.append(('separator', None)) + + +def test_menu_builder_creates_nested_menus_icons_actions_and_separators(): + root = FakeMenu() + called = [] + + def action(): + called.append(True) + + MenuBuilder(icon_factory=lambda name: 'icon:' + name).create_menu( + root, + { + 'name': 'Top', + 'menu_items': [ + {'name': 'Plain', 'action': action}, + {'separator': True}, + {'name': 'Icon', 'icon': 'resource', 'action': action}, + ], + }, + ) + + top = root.items[0][1] + plain = top.items[0][1] + icon = top.items[2][1] + + assert root.items[0][0] == 'menu' + assert plain.name == 'Plain' + assert plain.triggered.callbacks == [action] + assert top.items[1] == ('separator', None) + assert icon.name == 'Icon' + assert icon.icon == 'icon:resource' + assert icon.triggered.callbacks == [action] + + +class FakeContext(object): + def __init__(self): + self.items = [] + + def add(self, plugin_name, contribution, data): + self.items.append((plugin_name, contribution, data)) + + +class BrokenContext(object): + def add(self, plugin_name, contribution, data): + raise RuntimeError('broken context') + + +def test_setup_contexts_routes_ui_and_menu_contributions(): + class Plugin(object): + def get_ui_contributions(self): + return [{'context': 'mdi', 'key': 'browser'}] + + def get_menu_contributions(self): + return [{'location': 'file', 'name': 'Open'}] + + mdi_context = FakeContext() + menu_context = FakeContext() + data = object() + manager = PluginManager( + 'package.plugins', + 'plugins', + FakeConfig(), + 'app/plugins', + ) + manager.plugins = {'plugin': Plugin(), 'legacy': object()} + manager.register_context('mdi', mdi_context) + manager.register_context('menus', menu_context) + + manager.setup_contexts(data) + + assert mdi_context.items == [ + ('plugin', {'context': 'mdi', 'key': 'browser'}, data), + ] + assert menu_context.items == [ + ('plugin', {'location': 'file', 'name': 'Open'}, data), + ] + + +def test_setup_contexts_logs_and_skips_missing_unknown_and_broken_contexts(caplog): + class MissingContext(object): + def get_ui_contributions(self): + return [{'key': 'browser'}] + + class UnknownContext(object): + def get_ui_contributions(self): + return [{'context': 'unknown', 'key': 'browser'}] + + class BrokenGetter(object): + def get_ui_contributions(self): + raise RuntimeError('broken getter') + + class MalformedGetter(object): + def get_ui_contributions(self): + return {'context': 'mdi'} + + class MalformedItem(object): + def get_ui_contributions(self): + return ['not a dictionary'] + + class BrokenAdd(object): + def get_ui_contributions(self): + return [{'context': 'broken', 'key': 'browser'}] + + class MenuPlugin(object): + def get_menu_contributions(self): + return [{'location': 'file', 'name': 'Open'}] + + logger = logging.getLogger('test.plugins') + manager = PluginManager( + 'package.plugins', + 'plugins', + FakeConfig(), + 'app/plugins', + logger=logger, + ) + manager.plugins = { + 'missing': MissingContext(), + 'unknown': UnknownContext(), + 'broken_getter': BrokenGetter(), + 'malformed_getter': MalformedGetter(), + 'malformed_item': MalformedItem(), + 'broken_add': BrokenAdd(), + 'menu': MenuPlugin(), + } + manager.register_context('broken', BrokenContext()) + + with caplog.at_level(logging.ERROR, logger='test.plugins'): + manager.setup_contexts(object()) + + log_text = caplog.text.lower() + assert 'missing' in log_text + assert 'unknown' in log_text + assert 'broken_getter' in log_text + assert 'malformed_getter' in log_text + assert 'malformed_item' in log_text + assert 'broken_add' in log_text + assert 'menus' in log_text + + +def test_menu_context_renders_locations_paths_groups_order_and_action_options(caplog): + file_menu = FakeMenu('file') + tools_menu = FakeMenu('tools') + + def open_action(): + pass + + def heal_action(): + pass + + def save_action(): + pass + + logger = logging.getLogger('test.plugins') + context = MenuContext(icon_factory=lambda name: 'icon:' + name, logger=logger) + context.register_location('file', file_menu) + context.register_location('tools', tools_menu) + context.add( + 'plugin_b', + { + 'location': 'file', + 'path': ('Project',), + 'group': 'open', + 'order': 20, + 'name': 'Heal', + 'action': heal_action, + }, + {}, + ) + context.add( + 'plugin_a', + { + 'location': 'file', + 'path': ('Project',), + 'group': 'open', + 'order': 10, + 'name': 'Open', + 'icon': 'folder', + 'shortcut': 'Ctrl+O', + 'checkable': True, + 'enabled': False, + 'action': open_action, + }, + {}, + ) + context.add( + 'plugin_a', + { + 'location': 'file', + 'path': ('Project',), + 'group': 'save', + 'order': 5, + 'name': 'Save', + 'action': save_action, + }, + {}, + ) + context.add('plugin_c', {'location': 'tools', 'name': 'Tool'}, {}) + context.add('plugin_malformed', 'not a dictionary', {}) + context.add('plugin_skip', {'location': 'missing', 'name': 'Skip'}, {}) + + with caplog.at_level(logging.ERROR, logger='test.plugins'): + context.render() + + project_menu = file_menu.items[0][1] + open_item = project_menu.items[0][1] + heal_item = project_menu.items[1][1] + save_item = project_menu.items[3][1] + + assert file_menu.items[0][0] == 'menu' + assert project_menu.name == 'Project' + assert [item[0] for item in project_menu.items] == [ + 'action', + 'action', + 'separator', + 'action', + ] + assert [open_item.name, heal_item.name, save_item.name] == [ + 'Open', + 'Heal', + 'Save', + ] + assert open_item.icon == 'icon:folder' + assert open_item.shortcut == 'Ctrl+O' + assert open_item.checkable is True + assert open_item.enabled is False + assert open_item.triggered.callbacks == [open_action] + assert heal_item.triggered.callbacks == [heal_action] + assert save_item.checkable is False + assert save_item.enabled is True + assert tools_menu.items[0][1].name == 'Tool' + assert 'missing' in caplog.text.lower() + assert 'plugin_malformed' in caplog.text + +class RecordingHandler(logging.Handler): + def __init__(self): + super().__init__() + self.records = [] + + def emit(self, record): + self.records.append(record) + + +class ServicePlugin(BasePlugin): + def __init__(self, services): + super().__init__({}) + self._services = services + + def get_services(self): + return self._services + + +def test_base_plugin_exposes_no_services_by_default(): + plugin = BasePlugin({}) + + assert plugin.get_services() == {} + + +def test_collect_services_merges_base_and_plugin_services(): + logger = logging.getLogger('test.plugins.services') + manager = make_manager(logger=logger) + manager.plugins = { + 'alpha': ServicePlugin({'answer': 42}), + 'beta': ServicePlugin({'greeter': str.upper}), + } + + services = manager.collect_services({'execute_command': object()}) + + assert 'execute_command' in services + assert services['answer'] == 42 + assert services['greeter'] is str.upper + assert manager.services is services + + +def test_collect_services_skips_invalid_and_conflicting_entries(): + logger = logging.getLogger('test.plugins.services') + handler = RecordingHandler() + logger.setLevel(logging.DEBUG) + logger.addHandler(handler) + try: + manager = make_manager(logger=logger) + manager.plugins = { + 'alpha': ServicePlugin({'shared': 'first', 'unique': 'ok'}), + 'beta': ServicePlugin({'shared': 'second'}), + 'gamma': ServicePlugin(['not', 'a', 'mapping']), + } + + services = manager.collect_services() + + assert services['shared'] == 'first' + assert services['unique'] == 'ok' + messages = [record.getMessage() for record in handler.records] + assert any('must be a mapping' in message for message in messages) + assert any( + 'conflicts with an existing service' in message + for message in messages + ) + finally: + logger.removeHandler(handler) + + +def test_legacy_plugin_setup_complete_runs_as_default_activity(): + calls = [] + data = {'services': object()} + + class LegacySetupPlugin(BasePlugin): + def plugin_setup_complete(self, setup_data): + calls.append(('legacy', setup_data)) + + manager = make_manager() + manager.plugins = {'legacy': LegacySetupPlugin({})} + + manager.setup_complete(data) + + assert calls == [('legacy', data)] + + +def test_duck_typed_plugin_setup_complete_still_runs(): + calls = [] + data = {'services': object()} + + class DuckTypedSetupPlugin(object): + def plugin_setup_complete(self, setup_data): + calls.append(('duck', setup_data)) + + manager = make_manager() + manager.plugins = {'duck': DuckTypedSetupPlugin()} + + manager.setup_complete(data) + + assert calls == [('duck', data)] + + +def test_multiple_setup_activities_run_in_priority_order(): + calls = [] + data = {'app': object()} + + class ActivityPlugin(BasePlugin): + def get_setup_activities(self): + return [ + { + 'name': 'late', + 'priority': DEFAULT_SETUP_PRIORITY + 10, + 'action': self.late, + }, + { + 'name': 'early', + 'priority': DEFAULT_SETUP_PRIORITY - 10, + 'action': self.early, + }, + ] + + def early(self, setup_data): + calls.append(('early', setup_data)) + + def late(self, setup_data): + calls.append(('late', setup_data)) + + manager = make_manager() + manager.plugins = {'activity': ActivityPlugin({})} + + manager.setup_complete(data) + + assert calls == [('early', data), ('late', data)] + + +def test_setup_activity_ordering_is_deterministic_across_plugins(): + calls = [] + + class NamedActivityPlugin(BasePlugin): + def __init__(self, label): + super().__init__({}) + self.label = label + + def get_setup_activities(self): + return [ + { + 'name': 'same_priority', + 'priority': DEFAULT_SETUP_PRIORITY, + 'action': self.record, + }, + ] + + def record(self, data): + del data + calls.append(self.label) + + manager = make_manager() + manager.plugins = { + 'zeta': NamedActivityPlugin('zeta'), + 'alpha': NamedActivityPlugin('alpha'), + } + + manager.setup_complete({}) + + assert calls == ['alpha', 'zeta'] + + +def test_setup_activity_names_break_priority_and_plugin_ties(): + calls = [] + + class TiedActivityPlugin(BasePlugin): + def get_setup_activities(self): + return [ + { + 'name': 'second', + 'priority': DEFAULT_SETUP_PRIORITY, + 'action': self.second, + }, + { + 'name': 'first', + 'priority': DEFAULT_SETUP_PRIORITY, + 'action': self.first, + }, + ] + + def first(self, data): + del data + calls.append('first') + + def second(self, data): + del data + calls.append('second') + + manager = make_manager() + manager.plugins = {'plugin': TiedActivityPlugin({})} + + manager.setup_complete({}) + + assert calls == ['first', 'second'] + + +def test_no_argument_plugin_setup_complete_fallback_still_runs(caplog): + calls = [] + + class NoArgumentSetupPlugin(BasePlugin): + def plugin_setup_complete(self): + calls.append('legacy-no-arg') + + logger = logging.getLogger('test.plugins.setup') + manager = make_manager(logger=logger) + manager.plugins = {'legacy': NoArgumentSetupPlugin({})} + + with caplog.at_level(logging.WARNING, logger='test.plugins.setup'): + manager.setup_complete({'unused': object()}) + + assert calls == ['legacy-no-arg'] + assert 'using old API' in caplog.text