diff --git a/shepherd/agent/cli.py b/shepherd/agent/cli.py new file mode 100644 index 0000000..bac403e --- /dev/null +++ b/shepherd/agent/cli.py @@ -0,0 +1,255 @@ + + +import logging +import os +import sys +import glob +from types import SimpleNamespace +from datetime import datetime +from pprint import pprint +import pkg_resources + +import chromalog +import click + +from . import core + +chromalog.basicConfig(level=os.environ.get("LOGLEVEL", "INFO")) + +log = logging.getLogger("shepherd.agent") + + +def echo_heading(title, on_nl=True): + if on_nl: + click.echo("") + click.echo(click.style(".: ", fg='blue', bold=True) + + click.style(title, fg='white', bold=True) + + click.style(" :.", fg='blue', bold=True)) + + +def echo_section(title, input_text=None, on_nl=True): + if on_nl: + click.echo("") + click.secho(":: ", bold=True, fg='blue', nl=False) + click.secho(title, bold=True, nl=False) + if input_text: + click.secho(F" {input_text}", fg='green', bold=True, nl=False) + click.echo("") + + +@click.group(invoke_without_command=True) +@click.option('-c', '--config', 'default_config_path', type=click.Path(), + help="Shepherd config TOML file to be used as default config layer." + " Overrides default './shepherd*.toml' search") +@click.option('-l', '--local', 'local_operation', is_flag=True, + help="Only use the local config layers (default and custom), and disable all" + " Shepherd Control remote features") +@click.option('-d', '--default-config-only', 'only_default_layer', is_flag=True, + help="Ignore the custom config layer (still uses the Control config above that)") +@click.option('-n', '--new', 'new_run', is_flag=True, + help="Clear existing device identity and cached Shepherd Control config layer." + " Also triggered by the presence of a shepherd.new file in the" + " same directory as the custom config layer file.") +@click.pass_context +def cli(ctx, default_config_path, local_operation, only_default_layer, new_run): + """ + Core service. If default config file is not provided with '-c' option, the first filename + in the current working directory beginning with "shepherd" and + ending with ".toml" will be used. + """ + ctx.ensure_object(SimpleNamespace) + + version_text = pkg_resources.get_distribution("shepherd") + log.info(F"Shepherd Agent [{version_text}]") + + # Drop down to subcommand if it doesn't need default config file processing + if ctx.invoked_subcommand == "template": + return + + # Get a default config path to use + if not default_config_path: + default_config_path = sorted(glob.glob("./shepherd*.toml"))[:1] + if default_config_path: + default_config_path = default_config_path[0] + log.info(F"No default config file provided, found {default_config_path}") + with open(default_config_path, 'r+') as f: + content = f.read() + if "Compiled Shepherd config" in content: + log.warning("Default config file looks like it is full compiled config" + " file generated by Shepherd and picked up due to accidental" + " name match") + else: + log.error("No default config file provided, and no 'shepherd*.toml' could be" + " found in the current directory") + sys.exit(1) + + # Establish what config layers we're going to try and use + layers_disabled = [] + if local_operation or (ctx.invoked_subcommand == "test"): + layers_disabled.append("control") + log.info("Running in local only mode") + + if only_default_layer: + layers_disabled.append("custom") + + agent = core.Agent(control_enabled) + agent.load(default_config_path, use_custom_config, new_device_mode) + + # Drop down to subcommands that needed a config compiled + if ctx.invoked_subcommand == "test": + ctx.obj.agent = agent + return + + agent.start() + + print(str(datetime.now())) + + if ctx.invoked_subcommand is not None: + return + + print('Press Ctrl+{0} to exit'.format('Break' if os.name == 'nt' else 'C')) + try: + scheduler.start() + except (KeyboardInterrupt, SystemExit): + pass + + +@cli.command() +@click.argument('plugin_name', required=False) +@click.argument('interface_function', required=False) +@click.pass_context +def test(ctx, plugin_name, interface_function): + echo_heading("Shepherd Test") + + if not plugin_name: + + echo_section("Plugins loaded:") + if len(ctx.obj.plugin_configs) == 0: + click.echo("---none---") + for plugin_name, config in ctx.obj.plugin_configs.items(): + click.secho(F" {plugin_name}", fg='green') + + echo_section("Core config:") + pprint(ctx.obj.core_config) + + echo_section("Plugin configs:") + if len(ctx.obj.plugin_configs) == 0: + click.echo("---none---") + for name, config in ctx.obj.plugin_configs.items(): + click.secho(F" {plugin_name}", fg='green') + pprint(config) + + click.echo("") + + log.info("Initialising plugins...") + plugin.init_plugins(ctx.obj.plugin_configs, ctx.obj.core_config) + log.info("Plugin initialisation done") + + return + + echo_section("Target plugin:", input_text=plugin_name, on_nl=False) + # TODO find plugin dependancies + + if plugin_name not in ctx.obj.plugin_configs: + log.error(F"Supplied plugin name '{plugin_name}' is not loaded" + " (not present in config)") + sys.exit(1) + + echo_section(F"Config [{plugin_name}]:") + pprint(ctx.obj.plugin_configs[plugin_name]) + + interface = plugin.load_plugin(plugin_name) + + if not interface_function: + echo_section(F"Interface functions [{plugin_name}]:") + + for name, func in interface._functions.items(): + click.echo(F" {name}") + return + + echo_section("Target interface function:", input_text=interface_function) + if interface_function not in interface._functions: + log.error(F"Supplied interface function name '{interface_function}' is not present in" + F" plugin {plugin_name}") + sys.exit(1) + + log.info("Initialising plugins...") + plugin.init_plugins({plugin_name: ctx.obj.plugin_configs[plugin_name]}, ctx.obj.core_config) + log.info("Plugin initialisation done") + + interface._functions[interface_function]() + + +@cli.command() +@click.argument('plugin_name', required=False) +@click.option('-a', '--include-all', is_flag=True, + help="Include all optional fields in the template") +@click.option('-c', '--config', 'config_path', type=click.Path(), + help="Path to append or create config tempalate") +@click.option('-d', '--plugin-dir', type=click.Path(), + help="Directory to search for plugin modules, in addition to built in Shepherd" + " plugins and the global import path. Defaults to current directory.") +@click.pass_context +def template(ctx, plugin_name, include_all, config_path, plugin_dir): + """ + Generate a template config TOML file for PLUGIN_NAME, or for the Shepherd core if + PLUGIN_NAME is not provided. + + If config path is provided ("-c"), append to that file (if it exists) or write to + a new file (if it doesn't yet exist). + """ + + if not plugin_dir: + plugin_dir = Path.cwd() + + confspec = ConfigSpecification() + if (not plugin_name) or (plugin_name == "shepherd"): + plugin_name = "shepherd" + confspec = core_confspec() + else: + try: + plugin_interface = plugin.load_plugin(plugin_name, plugin_dir) + except plugin.PluginLoadError as e: + log.error(e.args[0]) + sys.exit(1) + confspec = plugin_interface.confspec + + template_dict = confspec.get_template(include_all) + template_toml = toml.dumps({plugin_name: template_dict}) + + log.info(F"Config template for [{plugin_name}]: \n\n"+template_toml) + + if not config_path: + # reuse parent "-c" for convenience + config_path = ctx.parent.params["default_config_path"] + + if not config_path: + return + + if Path(config_path).is_file(): + try: + existing_config = toml.load(config_path) + except Exception: + click.confirm( + F"File {config_path} already exists and is not a valid TOML file. Overwrite?", + default=True, abort=True) + + click.echo(F"Writing [{plugin_name}] template to {config_path}") + with open(config_path, 'w+') as f: + f.write(template_toml) + else: + if plugin_name in existing_config: + click.confirm(F"Overwrite [{plugin_name}] section in {config_path}?", + default=True, abort=True) + click.echo(F"Overwriting [{plugin_name}] section in {config_path}") + else: + click.confirm(F"Add [{plugin_name}] section to {config_path}?", + default=True, abort=True) + click.echo(F"Adding [{plugin_name}] section to {config_path}") + existing_config[plugin_name] = template_dict + with open(config_path, 'w+') as f: + f.write(toml.dumps(existing_config)) + else: + click.echo(F"Writing [{plugin_name}] template to {config_path}") + with open(config_path, 'w+') as f: + f.write(template_toml) diff --git a/shepherd/agent/core.py b/shepherd/agent/core.py index 463ce32..53fbd4c 100644 --- a/shepherd/agent/core.py +++ b/shepherd/agent/core.py @@ -3,303 +3,125 @@ Core shepherd module, tying together main service functionality. Provides main C """ -import os -import sys from pathlib import Path -import glob from copy import deepcopy from datetime import datetime -from types import SimpleNamespace -from pprint import pprint import logging -import chromalog -import pkg_resources -import click +import os from configspec import * -from . import scheduler from . import plugin from . import control -chromalog.basicConfig(level=os.environ.get("LOGLEVEL", "INFO")) log = logging.getLogger("shepherd.agent") -def echo_heading(title, on_nl=True): - if on_nl: - click.echo("") - click.echo(click.style(".: ", fg='blue', bold=True) + - click.style(title, fg='white', bold=True) + - click.style(" :.", fg='blue', bold=True)) - - -def echo_section(title, input_text=None, on_nl=True): - if on_nl: - click.echo("") - click.secho(":: ", bold=True, fg='blue', nl=False) - click.secho(title, bold=True, nl=False) - if input_text: - click.secho(F" {input_text}", fg='green', bold=True, nl=False) - click.echo("") - - -@click.group(invoke_without_command=True) -@click.option('-c', '--config', 'default_config_path', type=click.Path(), - help="Shepherd config TOML file to be used as default config layer." - " Overrides default './shepherd*.toml' search") -@click.option('-l', '--local', 'local_operation', is_flag=True, - help="Only use the local config layers (default and custom), and disable all" - " Shepherd Control remote features") -@click.option('-d', '--default-config-only', 'only_default_layer', is_flag=True, - help="Ignore the custom config layer (still uses the Control config above that)") -@click.option('-n', '--new', 'new_run', is_flag=True, - help="Clear existing device identity and cached Shepherd Control config layer." - " Also triggered by the presence of a shepherd.new file in the" - " same directory as the custom config layer file.") -@click.pass_context -def cli(ctx, default_config_path, local_operation, only_default_layer, new_run): +class Agent(): """ - Core service. If default config file is not provided with '-c' option, the first filename - in the current working directory beginning with "shepherd" and - ending with ".toml" will be used. + Holds the main state required to run Shepherd Agent """ - ctx.ensure_object(SimpleNamespace) - - version_text = pkg_resources.get_distribution("shepherd") - log.info(F"Shepherd Agent [{version_text}]") - - if ctx.invoked_subcommand == "template": - return - if not default_config_path: - default_config_path = sorted(glob.glob("./shepherd*.toml"))[:1] - if default_config_path: - default_config_path = default_config_path[0] - log.info(F"No default config file provided, found {default_config_path}") - with open(default_config_path, 'r+') as f: - content = f.read() - if "Compiled Shepherd config" in content: - log.warning("Default config file looks like it is full compiled config" - " file generated by Shepherd and picked up due to accidental" - " name match") + def __init__(self, control_enabled=True): + # The config defined by the device (everything before the Control layer) + self.local_config = None + # The config actually being used + self.applied_config = None + # Split the applied_config up into core and plugins + self.core_config = None + self.plugin_configs = None + + self.interface_functions = None + + self.control_enabled = control_enabled + + def load(self, default_config_path, use_custom_config=True, new_device_mode=False): + """ + Load in the Shepherd Agent config and associated plugins. + Args: + default_config_path: The path to the default config file + use_custom_config: Set False to disable the local custom config layer + control_enabled: Set False to disable Shepherd Control remote management + (including any cached Control config layer) + new_device_mode: Set True to clear out any cached state and trigger new generation + of ID, as if it were being run on a fresh system. + """ + + # Compile the config layers + confman = ConfigManager() + compile_local_config(confman, default_config_path, use_custom_config) + self.local_config = deepcopy(confman.get_config_bundles()) + + # Check for new device mode + core_conf = confman.get_config_bundle('shepherd') + + if new_device_mode or check_new_device_file(core_conf["custom_config_path"]): + log.info("'new device' mode enabled, clearing old state...") + control.generate_device_identity(core_conf["root_dir"]) + control.clear_cached_config(core_conf["root_dir"]) + + if self.control_enabled: + compile_remote_config(confman) else: - log.error("No default config file provided, and no 'shepherd*.toml' could be" - " found in the current directory") - sys.exit(1) - - layers_disabled = [] - if local_operation or (ctx.invoked_subcommand == "test"): - layers_disabled.append("control") - log.info("Running in local only mode") - - if only_default_layer: - layers_disabled.append("custom") - - confman = ConfigManager() - - compile_config(confman, default_config_path, layers_disabled, new_run) - - plugin_configs = confman.get_config_bundles() - del plugin_configs["shepherd"] - core_config = confman.get_config_bundle("shepherd") - - if ctx.invoked_subcommand == "test": - ctx.obj.plugin_configs = plugin_configs - ctx.obj.core_config = core_config - return - - applied_config = confman.get_config_bundles() - # Not part of normal ConfigManager, we just saved this here to pass it to Control - local_config = confman.saved_local_config - - core_update_state = control.CoreUpdateState(local_config, applied_config) - - plugin_update_states = {name: iface._update_state - for name, iface in plugin.plugin_interfaces.items()} - - if core_config["control"] is not None: - control.init_control(core_config["control"], core_config["root_dir"], - core_update_state, plugin_update_states) - else: - log.warning("Shepherd control config section not present. Will not attempt to connect to" - " Shepherd Control server.") - - scheduler.init_scheduler(core_config) - plugin.init_plugins(plugin_configs, core_config) - # scheduler.restore_jobs() - - print(str(datetime.now())) - - if ctx.invoked_subcommand is not None: - return - - print('Press Ctrl+{0} to exit'.format('Break' if os.name == 'nt' else 'C')) - try: - scheduler.start() - except (KeyboardInterrupt, SystemExit): - pass - - -@cli.command() -@click.argument('plugin_name', required=False) -@click.argument('interface_function', required=False) -@click.pass_context -def test(ctx, plugin_name, interface_function): - echo_heading("Shepherd Test") - - if not plugin_name: - - echo_section("Plugins loaded:") - if len(ctx.obj.plugin_configs) == 0: - click.echo("---none---") - for plugin_name, config in ctx.obj.plugin_configs.items(): - click.secho(F" {plugin_name}", fg='green') - - echo_section("Core config:") - pprint(ctx.obj.core_config) - - echo_section("Plugin configs:") - if len(ctx.obj.plugin_configs) == 0: - click.echo("---none---") - for name, config in ctx.obj.plugin_configs.items(): - click.secho(F" {plugin_name}", fg='green') - pprint(config) - - click.echo("") + log.info("Shepherd Control config layer disabled") - log.info("Initialising plugins...") - plugin.init_plugins(ctx.obj.plugin_configs, ctx.obj.core_config) - log.info("Plugin initialisation done") + self.applied_config = confman.get_config_bundles() + self.plugin_configs = confman.get_config_bundles() + self.core_config = self.plugin_configs.pop('shepherd') - return - - echo_section("Target plugin:", input_text=plugin_name, on_nl=False) - # TODO find plugin dependancies - - if plugin_name not in ctx.obj.plugin_configs: - log.error(F"Supplied plugin name '{plugin_name}' is not loaded" - " (not present in config)") - sys.exit(1) - - echo_section(F"Config [{plugin_name}]:") - pprint(ctx.obj.plugin_configs[plugin_name]) - - interface = plugin.load_plugin(plugin_name) + log.debug("Compiled config: %s", confman.root_config) + if core_conf["compiled_config_path"]: + message = F"Compiled Shepherd config at {datetime.now()}" + confman.dump_to_file(core_conf["compiled_config_path"], message=message) + log.info(F"Saved compiled config to {core_conf['compiled_config_path']}") - if not interface_function: - echo_section(F"Interface functions [{plugin_name}]:") + def start(self): - for name, func in interface._functions.items(): - click.echo(F" {name}") - return + plugin_interfaces, self.interface_functions = plugin.init_plugins( + self.plugin_configs, self.core_config, {}) - echo_section("Target interface function:", input_text=interface_function) - if interface_function not in interface._functions: - log.error(F"Supplied interface function name '{interface_function}' is not present in" - F" plugin {plugin_name}") - sys.exit(1) - - log.info("Initialising plugins...") - plugin.init_plugins({plugin_name: ctx.obj.plugin_configs[plugin_name]}, ctx.obj.core_config) - log.info("Plugin initialisation done") - - interface._functions[interface_function]() - - -@cli.command() -@click.argument('plugin_name', required=False) -@click.option('-a', '--include-all', is_flag=True, - help="Include all optional fields in the template") -@click.option('-c', '--config', 'config_path', type=click.Path(), - help="Path to append or create config tempalate") -@click.option('-d', '--plugin-dir', type=click.Path(), - help="Directory to search for plugin modules, in addition to built in Shepherd" - " plugins and the global import path. Defaults to current directory.") -@click.pass_context -def template(ctx, plugin_name, include_all, config_path, plugin_dir): - """ - Generate a template config TOML file for PLUGIN_NAME, or for the Shepherd core if - PLUGIN_NAME is not provided. + cmd_runner = control.CommandRunner(self.interface_functions) + core_update_state = control.CoreUpdateState(cmd_runner.cmd_reader, + cmd_runner.cmd_result_writer) + core_update_state.set_static_state(self.local_config, self.applied_config, core_confspec()) - If config path is provided ("-c"), append to that file (if it exists) or write to - a new file (if it doesn't yet exist). - """ + plugin_update_states = {name: iface._update_state + for name, iface in plugin_interfaces.items()} - if not plugin_dir: - plugin_dir = Path.cwd() - - confspec = ConfigSpecification() - if (not plugin_name) or (plugin_name == "shepherd"): - plugin_name = "shepherd" - confspec = core_confspec() - else: - try: - plugin_interface = plugin.load_plugin(plugin_name, plugin_dir) - except plugin.PluginLoadError as e: - log.error(e.args[0]) - sys.exit(1) - confspec = plugin_interface.confspec + if self.control_enabled: + if self.core_config["control"] is not None: + control.start_control(self.core_config["control"], self.core_config["root_dir"], + core_update_state, plugin_update_states) + else: + log.warning("Shepherd control config section not present. Will not attempt to" + " connect to Shepherd Control server.") - template_dict = confspec.get_template(include_all) - template_toml = toml.dumps({plugin_name: template_dict}) + # tasks.init_tasks(self.core_config) # seperate tasks.start? - log.info(F"Config template for [{plugin_name}]: \n\n"+template_toml) + # plugin.start() # Run the plugin `.run` hooks in seperate threads - if not config_path: - # reuse parent "-c" for convenience - config_path = ctx.parent.params["default_config_path"] + # scheduler.restore_jobs() - if not config_path: - return - if Path(config_path).is_file(): - try: - existing_config = toml.load(config_path) - except Exception: - click.confirm( - F"File {config_path} already exists and is not a valid TOML file. Overwrite?", - default=True, abort=True) - - click.echo(F"Writing [{plugin_name}] template to {config_path}") - with open(config_path, 'w+') as f: - f.write(template_toml) - else: - if plugin_name in existing_config: - click.confirm(F"Overwrite [{plugin_name}] section in {config_path}?", - default=True, abort=True) - click.echo(F"Overwriting [{plugin_name}] section in {config_path}") - else: - click.confirm(F"Add [{plugin_name}] section to {config_path}?", - default=True, abort=True) - click.echo(F"Adding [{plugin_name}] section to {config_path}") - existing_config[plugin_name] = template_dict - with open(config_path, 'w+') as f: - f.write(toml.dumps(existing_config)) - else: - click.echo(F"Writing [{plugin_name}] template to {config_path}") - with open(config_path, 'w+') as f: - f.write(template_toml) - - -def check_new_run_file(custom_config_path): +def check_new_device_file(custom_config_path): if not custom_config_path: return False trigger_path = Path(Path(custom_config_path).parent, 'shepherd.new') if trigger_path.exists(): trigger_path.unlink() - log.info("'shepherd.new' file detected, removing file and clearing old state...") + log.info("'shepherd.new' file detected, removing file and" + " triggering 'new device' mode") return True return False -def compile_config(confman, default_config_path, layers_disabled, new_run): +def compile_local_config(confman, default_config_path, use_custom_config): """ - Run through the process of assembling the various config layers, falling back to working - ones where necessary. As part of this, loads in the required plugins. + Load the default config and optionally try to overlay the custom config layer. + As part of this, load the required plugins into cache (required to validate their config). """ confman.add_confspec("shepherd", core_confspec()) @@ -316,7 +138,7 @@ def compile_config(confman, default_config_path, layers_disabled, new_run): F" {chr(10).join(e.args)}") else: log.error(F"Failed to load default config from {default_config_path}", exc_info=True) - sys.exit(1) + raise # Resolve and freeze local install paths that shouldn't be changed from default config core_conf = confman.get_config_bundle("shepherd") @@ -334,96 +156,90 @@ def compile_config(confman, default_config_path, layers_disabled, new_run): log.warning("Custom plugin path is empty, won't load custom plugins") # ====Custom Local Config Layer==== - # If this fails, maintain default config but continue on to Control layer - if "custom" in layers_disabled: + # If this fails, fallback to default config + if not use_custom_config: log.info("Custom config layer disabled") - elif not custom_config_path: + return + if not custom_config_path: log.warning("Custom config path is empty, skipping custom config layer") - else: - try: - load_config_layer_and_plugins(confman, custom_config_path) - log.info(F"Loaded custom config layer from {custom_config_path}") - except Exception as e: - if isinstance(e, InvalidConfigError): - log.error(F"Failed to load custom config layer from {custom_config_path}." - F" {e.args[0]}") - else: - log.error(F"Failed to load custom config layer from {custom_config_path}.", - exc_info=True) - log.warning("Falling back to default config.") - confman.fallback() + return + + try: + load_config_layer_and_plugins(confman, custom_config_path) + log.info(F"Loaded custom config layer from {custom_config_path}") + except Exception as e: + if isinstance(e, InvalidConfigError): + log.error(F"Failed to load custom config layer from {custom_config_path}." + F" {e.args[0]}") + else: + log.error(F"Failed to load custom config layer from {custom_config_path}.", + exc_info=True) + log.warning("Falling back to default config.") + confman.fallback() + + +def compile_remote_config(confman): + """ + Attempt to load and apply the Shepherd Control config layer (cached from prior communication, + Control hasn't actually started up yet). Falls back to previous config if it fails. + As part of this, load the required plugins into cache (required to validate their config). + """ + # ====Control Remote Config Layer==== # Freeze Shepherd Control related config. - core_conf = confman.get_config_bundle("shepherd") - resolve_core_conf_paths(core_conf, default_config_path.parent) confman.freeze_value("shepherd", "control_server") confman.freeze_value("shepherd", "control_api_key") # Save current good local config confman.save_fallback() - # Tuck it away so we can pass the local config to Control - confman.saved_local_config = deepcopy(confman.get_config_bundles()) - if check_new_run_file(custom_config_path) or new_run: - if new_run: - log.info("'new run' selected, clearing old state...") - control.generate_device_identity(core_conf["root_dir"]) - control.clear_cached_config(core_conf["root_dir"]) - - # ====Control Remote Config Layer==== - # If this fails, maintain current local config. - if "control" not in layers_disabled: + core_conf = confman.get_config_bundle("shepherd") + try: + control_config = control.get_cached_config(core_conf["root_dir"]) try: - control_config = control.get_cached_config(core_conf["root_dir"]) - try: - load_config_layer_and_plugins(confman, control_config) - log.info(F"Loaded cached Shepherd Control config layer") - except Exception as e: - if isinstance(e, InvalidConfigError): - log.error(F"Failed to load cached Shepherd Control config layer." - F" {e.args[0]}") - else: - log.error(F"Failed to load cached Shepherd Control config layer.", - exc_info=True) - log.warning("Falling back to local config.") - confman.fallback() - except Exception: - log.warning("No cached Shepherd Control config layer available.") - else: - log.info("Shepherd Control config layer disabled") - - log.debug("Compiled config: %s", confman.root_config) - if core_conf["compiled_config_path"]: - message = F"Compiled Shepherd config at {datetime.now()}" - confman.dump_to_file(core_conf["compiled_config_path"], message=message) - log.info(F"Saved compiled config to {core_conf['compiled_config_path']}") + load_config_layer_and_plugins(confman, control_config) + log.info(F"Loaded cached Shepherd Control config layer") + except Exception as e: + if isinstance(e, InvalidConfigError): + log.error(F"Failed to load cached Shepherd Control config layer." + F" {e.args[0]}") + else: + log.error(F"Failed to load cached Shepherd Control config layer.", + exc_info=True) + log.warning("Falling back to local config.") + confman.fallback() + except Exception: + log.warning("No cached Shepherd Control config layer available.") -# Relative pathnames here are all relative to "root_dir" +# Relative pathnames here are all relative to "root_dir". `root_dir` itself is relative to +# the directory the default config is loaded from def core_confspec(): """ Returns the core config specification """ confspec = ConfigSpecification() - 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=False, - helptext="Operating directory for shepherd to place" - " working files.")), - ("custom_config_path", StringSpec(optional=True, helptext="Path to custom config" - " layer TOML file.")), - ("compiled_config_path", StringSpec(default="compiled-config.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", control.control_confspec()) + + confspec.add_specs({ + "name": StringSpec(helptext="Identifying name for this device"), + }) + + confspec.add_specs(optional=True, spec_dict={ + "root_dir": + (StringSpec(helptext="Operating directory for shepherd to place working files."), + "./shepherd"), + "custom_config_path": + StringSpec(helptext="Path to custom config layer TOML file."), + "compiled_config_path": + (StringSpec(helptext="Path to custom file Shepherd will generate to show compiled" + " config that was used and any errors in validation."), + "compiled-config.toml"), + "plugin_dir": + (StringSpec(helptext="Optional directory for Shepherd to look for plugins in."), + "./shepherd-plugins") + }) + + confspec.add_spec("control", control.control_confspec(), optional=True) return confspec