From cb1dd52833e9503fda5d731d0a199ccd98605582 Mon Sep 17 00:00:00 2001 From: "Sam G." Date: Fri, 12 Jul 2024 03:01:13 -0700 Subject: [PATCH] add install/update subcommands, add key-val spec for templates --- symconf/__main__.py | 94 ++++++++++++++++++++++----------------- symconf/config.py | 106 ++++++++++++++++++++++++++++++++++++-------- symconf/util.py | 11 +++++ 3 files changed, 152 insertions(+), 59 deletions(-) diff --git a/symconf/__main__.py b/symconf/__main__.py index 6e60f3f..da1463b 100644 --- a/symconf/__main__.py +++ b/symconf/__main__.py @@ -4,20 +4,56 @@ from symconf import util from symconf.config import ConfigManager -def add_set_subparser(subparsers): - def update_app_settings(args): +def add_install_subparser(subparsers): + def install_apps(args): cm = ConfigManager(args.config_dir) - cm.update_apps( + cm.install_apps(apps=args.apps) + + parser = subparsers.add_parser( + 'install', + description='Run install scripts for registered applications.' + ) + parser.add_argument( + '-a', '--apps', + required = False, + default = "*", + type = lambda s: s.split(',') if s != '*' else s, + help = 'Application target for theme. App must be present in the registry. ' \ + + 'Use "*" to apply to all registered apps' + ) + parser.set_defaults(func=install_apps) + +def add_update_subparser(subparsers): + def update_apps(args): + cm = ConfigManager(args.config_dir) + cm.update_apps(apps=args.apps) + + parser = subparsers.add_parser( + 'update', + description='Run update scripts for registered applications.' + ) + parser.add_argument( + '-a', '--apps', + required = False, + default = "*", + type = lambda s: s.split(',') if s != '*' else s, + help = 'Application target for theme. App must be present in the registry. ' \ + + 'Use "*" to apply to all registered apps' + ) + parser.set_defaults(func=update_apps) + +def add_config_subparser(subparsers): + def config_apps(args): + cm = ConfigManager(args.config_dir) + cm.config_apps( apps=args.apps, scheme=args.scheme, palette=args.palette, ) parser = subparsers.add_parser( - 'set', - description='Generate theme files for various applications. Uses a template (in TOML ' \ - + 'format) to map application-specific config keywords to colors (in JSON ' \ - + 'format).' + 'config', + description='Set config files for registered applications.' ) parser.add_argument( '-p', '--palette', @@ -39,45 +75,20 @@ def add_set_subparser(subparsers): help = 'Application target for theme. App must be present in the registry. ' \ + 'Use "*" to apply to all registered apps' ) - parser.set_defaults(func=update_app_settings) - -def add_gen_subparser(subparsers): - parser = subparsers.add_parser( - 'gen', - description='Generate theme files for various applications. Uses a template (in TOML ' \ - + 'format) to map application-specific config keywords to colors (in JSON ' \ - + 'format).' - ) parser.add_argument( - '-a', '--app', - required=True, - help='Application target for theme. Supported: ["kitty"]' + '-T', '--template-vars', + required = False, + nargs='+', + action=util.KVPair, + help='Groups to use when populating templates, in the form group=value' ) - parser.add_argument( - '-p', '--palette', - required=True, - help='Palette to use for template mappings. Uses local "theme//colors.json".' - ) - parser.add_argument( - '-t', '--template', - default=None, - help='Path to TOML template file. If omitted, app\'s default template path is used.' \ - + 'If a directory is provided, all TOML files in the folder will be used.' - ) - parser.add_argument( - '-o', '--output', - default=None, - help='Output file path for theme. If omitted, app\'s default theme output path is used.' - ) - parser.set_defaults(func=generate_theme_files) + parser.set_defaults(func=config_apps) # central argparse entry point parser = argparse.ArgumentParser( 'symconf', - description='Generate theme files for various applications. Uses a template (in TOML ' \ - + 'format) to map application-specific config keywords to colors (in JSON ' \ - + 'format).' + description='Manage application configuration with symlinks.' ) parser.add_argument( '-c', '--config-dir', @@ -88,8 +99,9 @@ parser.add_argument( # add subparsers subparsers = parser.add_subparsers(title='subcommand actions') -#add_gen_subparser(subparsers) -add_set_subparser(subparsers) +add_install_subparser(subparsers) +add_update_subparser(subparsers) +add_config_subparser(subparsers) def main(): diff --git a/symconf/config.py b/symconf/config.py index 1fb4e3a..b6f2860 100644 --- a/symconf/config.py +++ b/symconf/config.py @@ -1,6 +1,7 @@ import os import re import json +import stat import inspect import tomllib import argparse @@ -12,8 +13,9 @@ from colorama import Fore, Back, Style from symconf import util from symconf.reader import DictReader + def y(t): - return Style.RESET_ALL + Fore.YELLOW + t + Style.RESET_ALL + return Style.RESET_ALL + Fore.BLUE + t + Style.RESET_ALL class ConfigManager: def __init__( @@ -92,6 +94,30 @@ class ConfigManager: return value + def _run_script( + self, + script, + ): + script_path = Path(script) + if script_path.stat().st_mode & stat.S_IXUSR == 0: + print( + f'{y("│")}' + Fore.RED + Style.DIM \ + + f' > script "{script_path.relative_to(self.config_dir)}" missing execute permissions, skipping' + ) + return + + print(f'{y("│")}' + Fore.BLUE + Style.DIM + f' > running script "{script_path.relative_to(self.config_dir)}"') + + output = subprocess.check_output(str(script_path), shell=True) + if output: + fmt_output = output.decode().strip().replace('\n',f'\n{y("│")} ') + print( + f'{y("│")}' + \ + Fore.BLUE + Style.DIM + \ + f' > captured script output "{fmt_output}"' \ + + Style.RESET_ALL + ) + def app_config_map(self, app_name) -> dict[str, Path]: ''' Get the config map for a provided app. @@ -650,11 +676,6 @@ class ConfigManager: links_fail.append((from_path, to_path)) continue - if not from_path.parent.exists(): - print(f'Target config parent directory for "{from_path}" doesn\'t exist, skipping') - links_fail.append((from_path, to_path)) - continue - # if config file being symlinked exists & isn't already a symlink (i.e., # previously set by this script), throw an error. if from_path.exists() and not from_path.is_symlink(): @@ -671,6 +692,10 @@ class ConfigManager: from_path.unlink(missing_ok=True) #print(f'Linking [{from_path}] -> [{to_path}]') + + # create parent directory if doesn't exist + from_path.parent.mkdir(parents=True, exist_ok=True) + from_path.symlink_to(to_path) links_succ.append((from_path, to_path)) @@ -685,20 +710,10 @@ class ConfigManager: to_p = to_p.relative_to(self.config_dir) print(f'{y("│")}' + Fore.RED + f' > failed to link {from_p} -> {to_p}') - for script in script_list: - print(f'{y("│")}' + Fore.BLUE + f' > running script "{script.relative_to(self.config_dir)}"') - output = subprocess.check_output(str(script), shell=True) - if output: - fmt_output = output.decode().strip().replace('\n',f'\n{y("│")} ') - print( - f'{y("│")}' + \ - Fore.BLUE + Style.DIM + \ - f' > captured script output "{fmt_output}"' \ - + Style.RESET_ALL - ) + self._run_script(script) - def update_apps( + def config_apps( self, apps: str | list[str] = '*', scheme = 'any', @@ -723,6 +738,11 @@ class ConfigManager: print(' > scheme :: ' + Fore.YELLOW + f'{scheme}\n' + Style.RESET_ALL) for app_name in app_list: + app_dir = Path(self.apps_dir, app_name) + if not app_dir.exists(): + # app has no directory, skip it + continue + self.update_app_config( app_name, app_settings=self.app_registry[app_name], @@ -731,3 +751,53 @@ class ConfigManager: strict=False, **kw_groups, ) + + def install_apps( + self, + apps: str | list[str] = '*', + ): + if apps == '*': + # get all registered apps + app_list = list(self.app_registry.keys()) + else: + # get requested apps that overlap with registry + app_list = [a for a in apps if a in self.app_registry] + + if not app_list: + print(f'None of the apps "{apps}" are registered, exiting') + return + + print('> symconf parameters: ') + print(' > registered apps :: ' + Fore.YELLOW + f'{app_list}' + Style.RESET_ALL) + + for app_name in app_list: + install_script = Path(self.apps_dir, app_name, 'install.sh') + if not install_script.exists(): + continue + + self._run_script(install_script) + + def update_apps( + self, + apps: str | list[str] = '*', + ): + if apps == '*': + # get all registered apps + app_list = list(self.app_registry.keys()) + else: + # get requested apps that overlap with registry + app_list = [a for a in apps if a in self.app_registry] + + if not app_list: + print(f'None of the apps "{apps}" are registered, exiting') + return + + print('> symconf parameters: ') + print(' > registered apps :: ' + Fore.YELLOW + f'{app_list}' + Style.RESET_ALL) + + for app_name in app_list: + update_script = Path(self.apps_dir, app_name, 'update.sh') + if not update_script.exists(): + continue + + self._run_script(update_script) diff --git a/symconf/util.py b/symconf/util.py index 1daeb55..9e9fcde 100644 --- a/symconf/util.py +++ b/symconf/util.py @@ -1,3 +1,4 @@ +import argparse from pathlib import Path from xdg import BaseDirectory @@ -19,3 +20,13 @@ def deep_update(mapping: dict, *updating_mappings: dict) -> dict: updated_mapping[k] = v return updated_mapping + +class KVPair(argparse.Action): + def __call__(self, parser, namespace, values, option_string=None): + kv_dict = getattr(namespace, self.dest, {}) + if kv_dict is None: + kv_dict = {} + for value in values: + key, val = value.split('=', 1) + kv_dict[key] = val + setattr(namespace, self.dest, kv_dict)