diff --git a/shepherd/config.py b/shepherd/config.py deleted file mode 100644 index 32cfb8d..0000000 --- a/shepherd/config.py +++ /dev/null @@ -1,420 +0,0 @@ -""" -Configuration management module. Enables configuration to be validated against -requirement definitions before being loaded and used. - -Compatible with both raw config data structures and TOML files, config data must -start with a root dict containing named "config bundles". These are intended to -refer to different modular parts of the application needing configuration, and the -config data structure must contain at least one. - -Each config bundle itself needs to have a dict at the root, and so in practice a minimal -TOML config file would look like:: - - [myapp] - config_thingy_a = "foooooo!" - important_number = 8237 - -This would resolve to a config bundle named "myapp" that results in the dict:: - - {"config_thingy_a": "foooooo!", "important_number": 8237} - -Root items that are not dicts are not supported, for instance both the following TOML files would fail:: - - [[myapp]] - important_number = 8237 - [[myapp]] - another_important_number = 2963 - -(root object in bundle is a list) - -:: - - root_thingy = 46 - -(root object in config is a single value) -""" - - -import re -import toml -from abc import ABC, abstractmethod -from copy import deepcopy - -from .freezedry import freezedryable, rehydrate - - - - -class InvalidConfigError(Exception): - pass - -# The Table and Array terms from the TOML convention essentially -# map directly to Dictionaries (Tables), and Lists (Arrays) - -class _ConfigDefinition(ABC): - def __init__(self, default=None, optional=False, helptext=""): - self.default = default - self.optional = optional - self.helptext = helptext - - @abstractmethod - def validate(self, value): - """ - Checks the supplied value to confirm that it complies with this ConfigDefinition. - Raises InvalidConfigError on failure. - """ - pass - - -@freezedryable -class BoolDef(_ConfigDefinition): - def __init__(self, default=None, optional=False, helptext=""): - super().__init__(default, optional, helptext) - - def validate(self, value): - if not isinstance(value, bool): - raise InvalidConfigError("Config value must be a boolean") - -@freezedryable -class IntDef(_ConfigDefinition): - def __init__(self, default=None, minval=None, maxval=None, - optional=False, helptext=""): - super().__init__(default, optional, helptext) - self.minval = minval - self.maxval = maxval - - def validate(self, value): - if not isinstance(value, int): - raise InvalidConfigError("Config value must be an integer") - if self.minval is not None and value < self.minval: - raise InvalidConfigError("Config value must be >= " + - str(self.minval)) - if self.maxval is not None and value > self.maxval: - raise InvalidConfigError("Config value must be <= " + - str(self.maxval)) - -@freezedryable -class StringDef(_ConfigDefinition): - def __init__(self, default="", minlength=None, maxlength=None, - optional=False, helptext=""): - super().__init__(default, optional, helptext) - self.minlength = minlength - self.maxlength = maxlength - - def validate(self, value): - if not isinstance(value, str): - raise InvalidConfigError(F"Config value must be a string and is {value}") - if self.minlength is not None and len(value) < self.minlength: - raise InvalidConfigError("Config string length must be >= " + - str(self.minlength)) - if self.maxlength is not None and len(value) > self.maxlength: - raise InvalidConfigError("Config string length must be <= " + - str(self.maxlength)) - -@freezedryable -class DictDef(_ConfigDefinition): - def __init__(self, default=None, optional=False, helptext=""): - super().__init__(default, optional, helptext) - self.def_dict = {} - - def add_def(self, name, newdef): - if not isinstance(newdef, _ConfigDefinition): - raise TypeError("Config definiton must be an instance of a " - "ConfigDefinition subclass") - if not isinstance(name, str): - raise TypeError("Config definition name must be a string") - self.def_dict[name] = newdef - return newdef - - def validate(self, value_dict): - """ - Checks the supplied value to confirm that it complies with this ConfigDefinition. - Raises InvalidConfigError on failure. - - This *can* modify the supplied value dict, inserting defaults for any child - ConfigDefinitions that are marked as optional. - """ - def_set = set(self.def_dict.keys()) - value_set = set(value_dict.keys()) - - for missing_key in def_set - value_set: - if not self.def_dict[missing_key].optional: - raise InvalidConfigError("Dict must contain key: " + - missing_key) - else: - value_dict[missing_key] = self.def_dict[missing_key].default - - for extra_key in value_set - def_set: - raise InvalidConfigError("Dict contains unknown key: " + - extra_key) - - for key, value in value_dict.items(): - try: - self.def_dict[key].validate(value) - except InvalidConfigError as e: - e.args = ("Key: " + key,) + e.args - raise - - def get_template(self, include_optional=False): - """ - Return a config dict with the minimum structure required for this ConfigDefinition. - Default values will be included, though not all required fields will necessarily have - defaults that successfully validate. - - Args: - include_optional: If set true, will include *all* config fields, not just the - required ones - Returns: - Dict containing the structure that should be passed back in (with values) to comply - with this ConfigDefinition. - """ - template = {} - for key, confdef in self.def_dict.items(): - if confdef.optional and (not include_optional): - continue - - if hasattr(confdef,"get_template"): - template[key]=confdef.get_template(include_optional) - else: - template[key]=confdef.default - return template - - -class _ListDefMixin(): - def validate(self, value_list): - if not isinstance(value_list, list): - raise InvalidConfigError("Config item must be a list") - for index, value in enumerate(value_list): - try: - super().validate(value) - except InvalidConfigError as e: - e.args = ("List index: " + str(index),) + e.args - raise - - def get_template(self, include_optional=False): - if hasattr(super(),"get_template"): - return [super().get_template(include_optional)] - else: - return [self.default] - -@freezedryable -class BoolListDef(_ListDefMixin, BoolDef): - pass - -@freezedryable -class IntListDef(_ListDefMixin, IntDef): - pass - -@freezedryable -class StringListDef(_ListDefMixin, StringDef): - pass - -@freezedryable -class DictListDef(_ListDefMixin, DictDef): - pass - -@freezedryable -class ConfDefinition(DictDef): - pass - - -class ConfigManager(): - def __init__(self): - self.root_config = {} - self.confdefs = {} - self.frozen_config = {} - - @staticmethod - def _load_source(source): - """ - Accept a filepath or opened file representing a TOML file, or a direct dict, - and return a plain parsed dict. - """ - if isinstance(source, dict): # load from dict - return source - elif isinstance(source, str): # load from pathname - with open(source, 'r') as conf_file: - return toml.load(conf_file) - else: # load from file - return toml.load(source) - - - def load(self, source): - """ - Load a config source into the ConfigManager, replacing any existing config. - - Args: - source: Either a dict config to load directly, a filepath to a TOML file, - or an open file. - """ - self.root_config = self._load_source(source) - self._overlay(self.frozen_config, self.root_config) - - def load_overlay(self, source): - """ - Load a config source into the ConfigManager, merging it over the top of any existing - config. Dicts will be recursively processed with keys being merged and existing values - being replaced by the new source. This includes lists, which will be treated as any other - value and completely replaced. - - Args: - source: Either the root dict of a data structure to load directly, a filepath to a TOML file, - or an open TOML file. - """ - self._overlay(self._load_source(source), self.root_config) - self._overlay(self.frozen_config, self.root_config) - - - def freeze_value(self, bundle_name, *field_names): - """ - Freeze the given config field so that subsequent calls to ``load`` and ``load_overlay`` - cannot change it. Can only be used for dict values or dict values nested in parent dicts. - - Args: - bundle_name: The name of the bundle to look for the field in. - *field_names: a series of strings that locate the config field, either a single - key or series of nested keys. - """ - - #Bundle names are really no different from any other nested dict - names = (bundle_name,) + field_names - - target_field = self.root_config - frozen_value = self.frozen_config - - # Cycle through nested names, creating frozen_config nested dicts as necessary - for name in names[:-1]: - target_field = target_field[name] - if name not in frozen_value: - frozen_value[name] = {} - frozen_value = frozen_value[name] - - - frozen_value[names[-1]] = target_field[names[-1]] - - - - - def add_confdef(self, bundle_name, confdef): - """ - Stores a ConfigDefinition for future use when validating the corresponding config bundle - - Args: - bundle_name (str) : The name to store the config definition under. - confdef (ConfigDefinition): The populated ConfigDefinition to store. - """ - self.confdefs[bundle_name]=confdef - - def add_confdefs(self, confdefs): - """ - Stores multiple ConfigDefinitions at once for future use when validating the corresponding config bundles - - Args: - confdefs : A dict of populated ConfigDefinitions to store, using their keys as names. - """ - self.confdefs.update(confdefs) - - def list_missing_confdefs(self): - """ - Returns a list of config bundle names that do not have a corresponding ConfigDefinition - stored in the ConfigManager. - """ - return list(self.root_config.keys() - self.confdefs.keys()) - - - def _overlay(self, src, dest): - for key in src: - # If the key is also in the dest and both are dicts, merge them. - if key in dest and isinstance(src[key], dict) and isinstance(dest[key], dict): - self._overlay(src[key], dest[key]) - else: - # Otherwise it's either an existing value to be replaced or needs to be added. - dest[key] = src[key] - - def get_config_bundle(self, bundle_name, conf_def=None): - """ - Get a config bundle called ``bundle_name`` and validate - it against the corresponding config definition stored in the ConfigManager. - If ``conf_def`` is supplied, it gets used instead. Returns a validated - config bundle dict. - - Note that as part of validation, optional keys that are missing will be - filled in with their default values (see ``DictDef``). This function will copy - the config bundle *after* validation, and so config loaded in the ConfManager will - be modified, but future ConfigManager manipulations won't change the returned config - bundle. - - Args: - config_name: (str) Name of the config dict to find. - conf_def: (ConfDefinition) Optional config definition to validate against. - """ - if not isinstance(conf_def, ConfDefinition): - conf_def = self.confdefs[bundle_name] - - if bundle_name not in self.root_config: - raise InvalidConfigError( - "Config must contain dict: " + bundle_name) - try: - conf_def.validate(self.root_config[bundle_name]) - except InvalidConfigError as e: - e.args = ("Bundle: " + bundle_name,) + e.args - raise - return deepcopy(self.root_config[bundle_name]) - - def get_config_bundles(self, bundle_names): - """ - Get multiple config bundles from the root dict at once, validating each one with the - corresponding confdef stored in the ConfigManager. See ``get_config_bundle`` - - Args: - bundle_names: A list of config bundle names to get. If dictionary is supplied, uses the values - as ConfigDefinitions rather than looking up a stored one in the ConfigManager. - - Returns: - A dict of config dicts, with keys matching those passed in ``bundle_names``. - """ - config_values = {} - if isinstance(bundle_names, dict): - for name, conf_def in bundle_names.items(): - config_values[name] = self.get_config_bundle(name, conf_def) - else: - for name in bundle_names: - config_values[name] = self.get_config_bundle(name) - return config_values - - def get_bundle_names(self): - """ - Returns a list of names of top level config bundles - """ - return list(self.root_config.keys()) - - def dump_toml(self): - return toml.dumps(self.root_config) - - def dump_to_file(self, filepath, message=None): - with open(filepath, 'w+') as f: - content = self.dump_toml() - if message is not None: - content = content.rstrip() + gen_comment(message) - f.write(content) - - -def strip_toml_message(string): - print("stripping...") - return re.sub("(?m)^#\\ shepherd_message:[^\\n]*$\\n?(?:^#[^\\n]+$\\n?)*", - '', string) - - -def update_toml_message(filepath, message): - with open(filepath, 'r+') as f: - content = f.read() - content = strip_toml_message(content).rstrip() - content += gen_comment(message) - f.seek(0) - f.write(content) - f.truncate() - - -def gen_comment(string): - return '\n# shepherd_message: ' + '\n# '.join(string.replace('#', '').splitlines()) + '\n' - diff --git a/shepherd/core.py b/shepherd/core.py index 916d8fb..2d2fd3f 100644 --- a/shepherd/core.py +++ b/shepherd/core.py @@ -6,214 +6,201 @@ Core shepherd module, tying together main service functionality. import os from pathlib import Path from datetime import datetime -import toml import logging +import pkg_resources import click -from copy import deepcopy + +from configspec import * from . import scheduler -from . import config from . import plugin from . import control -# Future implementations of checking config differences should be done on -# the hash of the nested conf dict, so comments shouldn't affect this. - -# save old config to somewhere in the shepherd root dir - probably need to -# implement a TOML writer in the config module. - -# later on, there's going to be an issue with a new config being applied -# remotely, then the system restarting, and an old edit in /boot being -# applied over the top... -# Fix this by saving the working config to /boot when new config applied -# remotely. - - -# Relative pathnames here are all relative to "root_dir" -def define_core_config(confdef): +@click.group(invoke_without_command=True) +#help="Path to default config TOML file" +@click.argument('default_config_path', required=False, type=click.Path()) +@click.pass_context +def cli(ctx, default_config_path): """ - Defines the config definition by populating the ConfigDefinition passed in ``confdef`` - the same pattern plugins use + Core service. Expects the default config to be set as an argument. """ - confdef.add_def("name", config.StringDef( - helptext="Identifying name for this device")) - - confdef.add_def("hostname", - config.StringDef(default="", optional=True, - helptext="If set, changes the system hostname")) - confdef.add_def("plugin_dir", - config.StringDef(default="~/shepherd-plugins", optional=True, - helptext="Optional directory for Shepherd to look for plugins in.")) - confdef.add_def("root_dir", - config.StringDef(default="~/shepherd", optional=True, - helptext="Operating directory for shepherd to place working files.")) - confdef.add_def("custom_config_path", - config.StringDef(optional=True, - helptext="Path to custom config layer TOML file.")) - confdef.add_def("generated_config_path", - config.StringDef(default="shepherd-generated.toml", optional=True, - helptext="Path to custom file Shepherd will generate to show compiled config that was used and any errors in validation.")) - - confdef.add_def("control_server", config.StringDef()) - confdef.add_def("control_api_key", config.StringDef()) + version_text = pkg_resources.get_distribution("shepherd") + logging.info(F"Initialising Shepherd Agent {version_text}") + # argparser = argparse.ArgumentParser(description="Keep track of a mob " + # "of roaming Pis") + # argparser.add_argument("configfile", nargs='?', metavar="configfile", + # help="Path to configfile", default="shepherd.toml") + # argparser.add_argument( + # '-e', '--noedit', help="Disable the editable config temporarily", action="store_true", default=False) + # argparser.add_argument("-t", "--test", help="Test and interface function of the from 'plugin:function'", + # default=None) + #args = argparser.parse_args() -def resolve_core_conf_paths(core_conf): - """ - Set the cwd to ``root_dir`` and resolve other core config paths relative to that. - Also expands out any "~" user characters. - """ - core_conf["root_dir"] = str(Path(core_conf["root_dir"]).expanduser().resolve()) - os.chdir(core_conf["root_dir"]) - core_conf["plugin_dir"] = str(Path(core_conf["plugin_dir"]).expanduser().resolve()) - core_conf["custom_config_path"] = str( - Path(core_conf["custom_config_path"]).expanduser().resolve()) - core_conf["generated_config_path"] = str( - Path(core_conf["generated_config_path"]).expanduser().resolve()) + confman = ConfigManager() + plugin_classes = compile_config_and_get_plugins(confman, default_config_path) -def load_config_layer(confman, config_source): - """ - Load a config layer, find the necessary plugin classes, then validate it. - """ - # Load in config layer - confman.load_overlay(config_source) + control.init_control(core_conf, plugin_configs) - # Get the core config so we can find the plugin directory - core_config = confman.get_config_bundle("shepherd") - plugin_dir = core_config["plugin_dir"] + scheduler.init_scheduler(core_conf) + plugin.init_plugins(plugin_classes, plugin_configs, core_config) + scheduler.restore_jobs() - # List other table names to get plugins we need to load - plugin_names = confman.get_bundle_names() - plugin_names.remove("shepherd") + print(str(datetime.now())) - # Load plugins to get their conf defs - plugin_classes = plugin.find_plugins(plugin_names, plugin_dir) - for plugin_name, plugin_class in plugin_classes.items(): - new_conf_def = config.ConfDefinition() - plugin_class.define_config(new_conf_def) - confman.add_confdef(plugin_name, new_conf_def) + if ctx.invoked_subcommand is not None: + return - # Get plugin configs - plugin_configs = confman.get_config_bundles(plugin_classes.keys()) - return (core_config, plugin_classes, plugin_configs) + print('Press Ctrl+{0} to exit'.format('Break' if os.name == 'nt' else 'C')) + try: + scheduler.start() + except (KeyboardInterrupt, SystemExit): + pass -def compile_config(default_config_path): +def compile_config_and_get_plugins(confman, default_config_path): """ Run through the process of assembling the various config layers, falling back to working ones where necessary. Also gathers needed plugin classes in the process. """ # Create core confdef and populate it - core_confdef = config.ConfDefinition() + core_confdef = ConfigSpecification() define_core_config(core_confdef) + confman.add_confspec("shepherd", core_confdef) - confman = config.ConfigManager() - confman.add_confdef("shepherd", core_confdef) - - # Default config. This must validate to continue. + # ====Default Local Config Layer==== + # This must validate to continue. + default_config_path = Path(default_config_path).expanduser() try: - core_conf, plugin_classes, plugin_configs = load_config_layer( - confman, Path(default_config_path).expanduser()) - logging.info(F"Loaded default config from {default_config_path}") - except: + plugin_classes = load_config_layer_and_plugins(confman, default_config_path) + logging.info(F"Loaded default config layer from {default_config_path}") + except Exception: logging.error(F"Failed to load default config from {default_config_path}") raise - # Resolve and freeze local install paths that shouldn't be changed or affect loading custom config + # Resolve and freeze local install paths that shouldn't be changed from default config + core_conf = confman.get_config_bundle("shepherd") + resolve_core_conf_paths(core_conf) confman.freeze_value("shepherd", "root_dir") confman.freeze_value("shepherd", "plugin_dir") confman.freeze_value("shepherd", "custom_config_path") confman.freeze_value("shepherd", "generated_config_path") - resolve_core_conf_paths(core_conf) - # Pull out custom config path and save current good root_config + # Pull out custom config path and save current good config custom_config_path = core_conf["custom_config_path"] - saved_root_config = deepcopy(confman.root_config) + confman.save_fallback() - # Custom config layer. If this fails, maintain default config but continue on to Control layer + # ====Custom Local Config Layer==== + # If this fails, maintain default config but continue on to Control layer try: - core_conf, plugin_classes, plugin_configs = load_config_layer( - confman, custom_config_path) - logging.info(F"Loaded custom config from {custom_config_path}") + plugin_classes = load_config_layer_and_plugins(confman, custom_config_path) + logging.info(F"Loaded custom config layer from {custom_config_path}") except Exception as e: logging.error( - F"Failed to load custom config from {custom_config_path}. Falling back to default config.", exc_info=e) - confman.root_config = saved_root_config + F"Failed to load custom config layer from {custom_config_path}. Falling back" + " to default config.", exc_info=e) + confman.fallback() # Freeze Shepherd Control related config. + core_conf = confman.get_config_bundle("shepherd") + resolve_core_conf_paths(core_conf) confman.freeze_value("shepherd", "control_server") confman.freeze_value("shepherd", "control_api_key") - resolve_core_conf_paths(core_conf) - # Save current good root_config - saved_root_config = deepcopy(confman.root_config) + # Save current good config + confman.save_fallback() - # Shepherd Control config layer. If this fails, maintain current local config. + # ====Control Remote Config Layer==== + # If this fails, maintain current local config. try: control_config = control.get_config(core_conf["root_dir"]) try: - core_conf, plugin_classes, plugin_configs = load_config_layer( - confman, control_config) - logging.info(F"Loaded cached Shepherd Control config") + plugin_classes = load_config_layer_and_plugins(confman, control_config) + logging.info(F"Loaded cached Shepherd Control config layer") except Exception as e: logging.error( - F"Failed to load cached Shepherd Control config. Falling back to local config.", exc_info=e) - confman.root_config = saved_root_config - except: - logging.warning("No cached Shepherd Control config available.") + F"Failed to load cached Shepherd Control config layer. Falling back" + " to local config.", exc_info=e) + confman.fallback() + except Exception: + logging.warning("No cached Shepherd Control config layer available.") + logging.debug("Compiled config: %s", confman.root_config) confman.dump_to_file(core_conf["generated_config_path"]) - return core_conf, plugin_classes, plugin_configs + return plugin_classes -@click.group(invoke_without_command = True) -#help="Path to default config TOML file" - -@click.argument('default_config', default="shepherd-default.toml", type=click.Path()) -@click.pass_context -def cli(ctx, default_config): +# Relative pathnames here are all relative to "root_dir" +def define_core_config(confspec): """ - Core service. Expects the default config to be set as an argument. + Defines the config definition by populating the ConfigSpecification passed in + ``confspec`` - the same pattern plugins use """ - #argparser = argparse.ArgumentParser(description="Keep track of a mob " - # "of roaming Pis") - #argparser.add_argument("configfile", nargs='?', metavar="configfile", - # help="Path to configfile", default="shepherd.toml") - #argparser.add_argument( - # '-e', '--noedit', help="Disable the editable config temporarily", action="store_true", default=False) - #argparser.add_argument("-t", "--test", help="Test and interface function of the from 'plugin:function'", - # default=None) + confspec.add_specs([ + ("name", StringSpec(helptext="Identifying name for this device")), + ("hostname", StringSpec(default="", optional=True, helptext="If set, changes the system" + " hostname")), + ("plugin_dir", StringSpec(default="~/shepherd-plugins", optional=True, + helptext="Optional directory for Shepherd to look for" + " plugins in.")), + ("root_dir", StringSpec(default="~/shepherd", optional=True, + helptext="Operating directory for shepherd to place" + " working files.")), + ("custom_config_path", StringSpec(optional=True, helptext="Path to custom config" + " layer TOML file.")), + ("generated_config_path", StringSpec(default="shepherd-generated.toml", optional=True, + helptext="Path to custom file Shepherd will generate" + " to show compiled config that was used and any" + " errors in validation.")) + ]) + + confspec.add_spec("control_server", StringSpec()) + confspec.add_spec("control_api_key", StringSpec()) - #args = argparser.parse_args() - core_conf, plugin_classes, plugin_configs = compile_config(default_config) +def resolve_core_conf_paths(core_conf): + """ + Set the cwd to ``root_dir`` and resolve other core config paths relative to that. + Also expands out any "~" user characters. + """ + core_conf["root_dir"] = str(Path(core_conf["root_dir"]).expanduser().resolve()) + os.chdir(core_conf["root_dir"]) + core_conf["plugin_dir"] = str(Path(core_conf["plugin_dir"]).expanduser().resolve()) + core_conf["custom_config_path"] = str( + Path(core_conf["custom_config_path"]).expanduser().resolve()) + core_conf["generated_config_path"] = str( + Path(core_conf["generated_config_path"]).expanduser().resolve()) - if args.test is None: - control.init_control(core_conf, plugin_configs) - scheduler.init_scheduler(core_conf) - plugin.init_plugins(plugin_classes, plugin_configs, core_conf) - scheduler.restore_jobs() +def load_config_layer_and_plugins(confman, config_source): + """ + Load a config layer, find the necessary plugin classes, then validate it. + If this succeeds, the returned dict of plugin classes will directly match + the bundle names in the config manager. + """ + # Load in config layer + confman.load_overlay(config_source) - print(str(datetime.now())) + # Get the core config so we can find the plugin directory + core_config = confman.get_config_bundle("shepherd") + plugin_dir = core_config["plugin_dir"] - if ctx.invoked_subcommand is not None: - return + # List other bundle names to get plugins we need to load + plugin_names = confman.get_bundle_names() + plugin_names.remove("shepherd") - print('Press Ctrl+{0} to exit'.format('Break' if os.name == 'nt' else 'C')) - try: - scheduler.start() - except (KeyboardInterrupt, SystemExit): - pass + # Load plugins to get their config specifications + plugin_classes = plugin.find_plugins(plugin_names, plugin_dir) + for plugin_name, plugin_class in plugin_classes.items(): + new_conf_spec = ConfigSpecification() + plugin_class.define_config(new_conf_spec) + confman.add_confspec(plugin_name, new_conf_spec) + + # Validate all plugin configs + confman.validate_bundles() -@click.argument('plugin_function') -@cli.command() -def test(): - if args.test is not None: - (test_plugin, test_func) = args.test.split(':') - func = getattr(shepherd.plugin.plugin_functions[test_plugin], test_func) - print(func()) - return \ No newline at end of file + return plugin_classes diff --git a/shepherd/plugin.py b/shepherd/plugin.py index d1b064d..26e98c1 100644 --- a/shepherd/plugin.py +++ b/shepherd/plugin.py @@ -39,7 +39,7 @@ class InterfaceFunction(): class Plugin(ABC): @staticmethod @abstractmethod - def define_config(confdef): + def specify_config(confspec): pass @abstractmethod @@ -52,8 +52,8 @@ class Plugin(ABC): class SimplePlugin(Plugin): @staticmethod - def define_config(confdef): - confdef.add_def() + def specify_config(confspec): + pass def __init__(self, plugininterface, config): super().__init__(plugininterface, config)