diff --git a/README.md b/README.md index 82d269b..933eab2 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # Overview `symconf` is a CLI tool for managing local application configuration. It uses a simple operational model that symlinks centralized config files to their expected locations across -the system. This central config directory can then be version controlled. +one's system. This central config directory can then be version controlled, and app +config files can be updated in one place. `symconf` also facilitates dynamically setting system and application "themes," symlinking matching theme config files for registered apps and running config reloading scripts. diff --git a/TODO.md b/TODO.md index 802b96b..11b4186 100644 --- a/TODO.md +++ b/TODO.md @@ -1 +1,3 @@ - Add local app caching for `auto` logic +- Better script handling: 1) should be live, basically reconnecting output to native + terminal, 2) catch and print errors from STDERR diff --git a/docs/reference/configuring.md b/docs/reference/configuring.md index c11f54f..957c5ba 100644 --- a/docs/reference/configuring.md +++ b/docs/reference/configuring.md @@ -7,6 +7,14 @@ to apply. The default location for this directory is your `$XDG_CONFIG_HOME` (e. `symconf` expects you to create two top-level components in your config directory: an `apps/` directory and an `app_registry.toml` file. +**High-level view**: + +- `symconf` operates on a single directory that houses all your config files +- Config files in this directory must be placed under an `apps//` folder to be + associated with the app `` +- For apps to be visible, you need an `app_registry.toml` file that tells `symconf` where + to symlink your files in `apps/` + ## Apps directory An `apps/` directory should be created in your config home, with a subdirectory `apps//` for each app with config files that you'd like to be visible to @@ -40,8 +48,8 @@ as follows: transparency, etc). Use `none` to indicate that the file does not correspond to any particular style group. - `config-name`: the _name_ of the config file. This should correspond to _same path - name_ that is expected by the app. For example, if your app expects a config file at - `a/b/c/d.conf`, "`d.conf`" is the path name. + name_ that is expected by the app being configured. For example, if your app expects a + config file at `a/b/c/d.conf`, "`d.conf`" is the path name. When invoking `symconf` with specific scheme and palette settings (see more in Usage), appropriate config files can be matched based on how you've named your files. diff --git a/docs/reference/matching.md b/docs/reference/matching.md index e69de29..bf90217 100644 --- a/docs/reference/matching.md +++ b/docs/reference/matching.md @@ -0,0 +1,17 @@ +# Matching +This file describes the naming and matching scheme employed by `symconf`. + +``` +~/.config/symconf/ +├── app_registry.toml +└── apps/ + └── / +    ├── user/ # user managed + │   └── none-none. +    ├── generated/ # automatically populated + │   └── none-none. +    ├── templates/ # config templates + │   └── none-none.template +    └── call/ # reload scripts +    └── none-none.sh +``` diff --git a/sym_tgt/test/aaa b/sym_tgt/test/aaa new file mode 120000 index 0000000..2a42474 --- /dev/null +++ b/sym_tgt/test/aaa @@ -0,0 +1 @@ +/home/smgr/Documents/projects/olog/symconf/tests/test-config-dir/apps/test/user/none-light.aaa \ No newline at end of file diff --git a/sym_tgt/test/ccc b/sym_tgt/test/ccc new file mode 120000 index 0000000..8bf12e3 --- /dev/null +++ b/sym_tgt/test/ccc @@ -0,0 +1 @@ +/home/smgr/Documents/projects/olog/symconf/tests/test-config-dir/apps/test/user/test-light.ccc \ No newline at end of file diff --git a/symconf/__init__.py b/symconf/__init__.py index a44cd5a..8f5b154 100644 --- a/symconf/__init__.py +++ b/symconf/__init__.py @@ -1,7 +1,12 @@ -from symconf.config import ConfigManager +from symconf.runner import Runner from symconf.reader import DictReader +from symconf.config import ConfigManager +from symconf.matching import Matcher, FilePart +from symconf.template import Template, FileTemplate, TOMLTemplate from symconf import config +from symconf import matching from symconf import reader +from symconf import template from symconf import theme from symconf import util diff --git a/symconf/__main__.py b/symconf/__main__.py index da1463b..e332b8d 100644 --- a/symconf/__main__.py +++ b/symconf/__main__.py @@ -43,12 +43,12 @@ def add_update_subparser(subparsers): parser.set_defaults(func=update_apps) def add_config_subparser(subparsers): - def config_apps(args): + def configure_apps(args): cm = ConfigManager(args.config_dir) - cm.config_apps( + cm.configure_apps( apps=args.apps, scheme=args.scheme, - palette=args.palette, + style=args.palette, ) parser = subparsers.add_parser( @@ -82,7 +82,7 @@ def add_config_subparser(subparsers): action=util.KVPair, help='Groups to use when populating templates, in the form group=value' ) - parser.set_defaults(func=config_apps) + parser.set_defaults(func=configure_apps) # central argparse entry point diff --git a/symconf/config.py b/symconf/config.py index b6f2860..353a1d8 100644 --- a/symconf/config.py +++ b/symconf/config.py @@ -1,21 +1,38 @@ +''' +Get the config map for a provided app. + +The config map is a dict mapping from config file **path names** to their absolute +path locations. That is, + +```sh + -> /apps///-. +``` + +For example, + +``` +palette1-light.conf.ini -> ~/.config/symconf/apps/user/palette1-light.conf.ini +palette2-dark.app.conf -> ~/.config/symconf/apps/generated/palette2-dark.app.conf +``` + +This ensures we have unique config names pointing to appropriate locations (which +is mostly important when the same config file names are present across ``user`` +and ``generated`` subdirectories; unique path names need to be resolved to unique +path locations). +''' import os -import re -import json -import stat -import inspect import tomllib -import argparse -import subprocess from pathlib import Path from colorama import Fore, Back, Style from symconf import util -from symconf.reader import DictReader +from symconf.util import printc, color_text +from symconf.runner import Runner +from symconf.template import FileTemplate, TOMLTemplate +from symconf.matching import Matcher, FilePart -def y(t): - return Style.RESET_ALL + Fore.BLUE + t + Style.RESET_ALL class ConfigManager: def __init__( @@ -39,19 +56,21 @@ class ConfigManager: self.config_dir = util.absolute_path(config_dir) self.apps_dir = Path(self.config_dir, 'apps') + self.group_dir = Path(self.config_dir, 'groups') self.app_registry = {} + self.matcher = Matcher() + self.runner = Runner() - self._check_paths() - + self._check_dirs() if not disable_registry: self._check_registry() - def _check_paths(self): + def _check_dirs(self): ''' - Check necessary paths for existence. + Check necessary config directories for existence. - Regardless of programmatic use or ``disable_registry``, we need to a valid + Regardless of programmatic use or ``disable_registry``, we need a valid ``config_dir`` and it must have an ``apps/`` subdirectory (otherwise there are simply no files to act on, not even when manually providing app settings). ''' @@ -68,291 +87,209 @@ class ConfigManager: ) def _check_registry(self): + ''' + Check the existence and format of the registry file + ``/app_registry.toml``. + + All that's needed to pass the format check is the existence of the key `"app"` in + the registry dict. If this isn't present, the TOML file is either incorrectly + configured, or it's empty and there are no apps to operate on. + ''' registry_path = Path(self.config_dir, 'app_registry.toml') if not registry_path.exists(): - print( - Fore.YELLOW \ - + f'No registry file found at expected location "{registry_path}"' + printc( + f'No registry file found at expected location "{registry_path}"', + Fore.YELLOW ) return app_registry = tomllib.load(registry_path.open('rb')) if 'app' not in app_registry: - print( - Fore.YELLOW \ - + f'Registry file found but is either empty or incorrectly formatted (no "app" key).' + printc( + 'Registry file found but is either empty or incorrectly formatted (no "app" key).', + Fore.YELLOW ) self.app_registry = app_registry.get('app', {}) def _resolve_group(self, group, value='auto'): + ''' + Resolve group inputs to concrete values. + + This method is mostly meant to handle values like ``auto`` which can be provided + by the user, but need to be interpreted in the system context (e.g., either + resolving to "any" or using the app's currently set option from the cache). + ''' if value == 'auto': # look group up in app cache and set to current value return 'any' return value - def _run_script( + def _symlink_paths( self, - script, + to_symlink: list[tuple[Path, Path]], ): - 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. + Symlink paths safely from target paths to internal config paths - The config map is a dict mapping from config file **path names** to their absolute - path locations. That is, + This method upholds the consistent symlink model: target locations are only + symlinked from if they don't exist or are already a symlink. We never overwrite + any concrete files, preventing accidental deletion of config files. This means + users must physically delete/move their existing configs into a ``symconf`` config + directory if they want it to be managed; otherwise, we don't touch it. - ```sh - -> /apps///-. - ``` - - For example, - - ``` - palette1-light.conf.ini -> ~/.config/symconf/apps/user/palette1-light.conf.ini - palette2-dark.app.conf -> ~/.config/symconf/apps/generated/palette2-dark.app.conf - ``` - - This ensures we have unique config names pointing to appropriate locations (which - is mostly important when the same config file names are present across ``user`` - and ``generated`` subdirectories). + Parameters: + to_symlink: path pairs to symlink, from target (external) path to source + (internal) path ''' - # first look in "generated", then overwrite with "user" - file_map = {} - user_app_dir = Path(self.apps_dir, app_name, 'user') - - if user_app_dir.is_dir(): - for conf_file in user_app_dir.iterdir(): - file_map[conf_file.name] = conf_file - - return file_map - - def _get_file_parts(self, pathnames): - # now match theme files in order of inc. specificity; for each unique config file - # tail, only the most specific matching file sticks - file_parts = [] - for pathname in pathnames: - parts = str(pathname).split('.') - - if len(parts) < 2: - print(f'Filename "{pathname}" incorrectly formatted, ignoring') + links_succ = [] + links_fail = [] + for from_path, to_path in to_symlink: + if not to_path.exists(): + print(f'Internal config path "{to_path}" doesn\'t exist, skipping') + links_fail.append((from_path, to_path)) continue - theme_part, conf_part = parts[0], '.'.join(parts[1:]) - file_parts.append((theme_part, conf_part, pathname)) - - return file_parts - - def _get_prefix_order( - self, - scheme, - palette, - strict=False, - ): - if strict: - theme_order = [ - (palette, scheme), - ] - else: - # inverse order of match relaxation; intention being to overwrite with - # results from increasingly relevant groups given the conditions - if palette == 'any' and scheme == 'any': - # prefer both be "none", with preference for specific scheme - theme_order = [ - (palette , scheme), - (palette , 'none'), - ('none' , scheme), - ('none' , 'none'), - ] - elif palette == 'any': - # prefer palette to be "none", then specific, then relax specific scheme - # to "none" - theme_order = [ - (palette , 'none'), - ('none' , 'none'), - (palette , scheme), - ('none' , scheme), - ] - elif scheme == 'any': - # prefer scheme to be "none", then specific, then relax specific palette - # to "none" - theme_order = [ - ('none' , scheme), - ('none' , 'none'), - (palette , scheme), - (palette , 'none'), - ] + # 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(): + printc( + f'Symlink target "{from_path}" exists and isn\'t a symlink, NOT overwriting; ' + f'please first manually remove this file so a symlink can be set.', + Fore.RED + ) + links_fail.append((from_path, to_path)) + continue else: - # neither component is any; prefer most specific - theme_order = [ - ('none' , 'none'), - ('none' , scheme), - (palette , 'none'), - (palette , scheme), - ] + # if path doesn't exist, or exists and is symlink, remove the symlink in + # preparation for the new symlink setting + from_path.unlink(missing_ok=True) - return theme_order + # create parent directory if doesn't exist + from_path.parent.mkdir(parents=True, exist_ok=True) - def match_pathnames( - self, - pathnames, - scheme, - palette, - prefix_order=None, - strict=False, - ): - ''' - Find and return matches along the "match trajectory." - ''' - file_parts = self._get_file_parts(pathnames) + from_path.symlink_to(to_path) + links_succ.append((from_path, to_path)) - if prefix_order is None: - prefix_order = self._get_prefix_order( - scheme, - palette, - strict=strict, + # link report + for from_p, to_p in links_succ: + from_p = util.to_tilde_path(from_p) + to_p = to_p.relative_to(self.config_dir) + print( + color_text("│", Fore.BLUE), + color_text( + f' > linked {color_text(from_p,Style.BRIGHT)} -> {color_text(to_p,Style.BRIGHT)}', + Fore.GREEN + ) ) - ordered_matches = [] - for i, (palette_prefix, scheme_prefix) in enumerate(prefix_order): - for theme_part, conf_part, pathname in file_parts: - theme_split = theme_part.split('-') - scheme_part = theme_split[-1] - palette_part = '-'.join(theme_split[:-1]) + for from_p, to_p in links_fail: + from_p = util.to_tilde_path(from_p) + to_p = to_p.relative_to(self.config_dir) + print( + color_text("│", Fore.BLUE), + color_text( + f' > failed to link {from_p} -> {to_p}', + Fore.RED + ) + ) - palette_match = palette_prefix == palette_part or palette_prefix == 'any' - scheme_match = scheme_prefix == scheme_part or scheme_prefix == 'any' - if palette_match and scheme_match: - ordered_matches.append((conf_part, theme_part, pathname, i+1)) - - return ordered_matches - - def _get_relaxed_set( - self, - match_list - ): - ''' - Mostly to filter "any" matches, latching onto a particular result and getting - only its relaxed variants. - - Note that palette-scheme files can be named ``--`` - ''' - if not match_list: - return [] - - match = match_list[-1] - theme_split = match[1].split('-') - palette_tgt, scheme_tgt = '-'.join(theme_split[:-1]), theme_split[-1] - - relaxed_map = {} - for conf_part, theme_part, pathname, idx in match_list: - #theme_split = theme_part.split('-')[::-1] - #scheme_part = theme_split[0] - #palette_part = theme_split[1] - theme_split = theme_part.split('-') - scheme_part = theme_split[-1] - palette_part = '-'.join(theme_split[:-1]) - #pvar_part = '-'.join(theme_split[2:]) - - palette_match = palette_part == palette_tgt or palette_part == 'none' - scheme_match = scheme_part == scheme_tgt or scheme_part == 'none' - - if palette_match and scheme_match: - relaxed_map[pathname] = (conf_part, theme_part, pathname, idx) - - return list(relaxed_map.values()) - - def _stack_toml( - self, - path_list - ): - stacked_dict = {} - for toml_path in path_list: - updated_map = tomllib.load(toml_path.open('rb')) - stacked_dict = util.deep_update(stacked_dict, updated_map) - - return stacked_dict - - def template_fill( - self, - template_str : str, - template_dict : dict, - pattern : str = r'f{{(\S+)}}', - ): - dr = DictReader.from_dict(template_dict) - return re.sub( - pattern, - lambda m:str(dr.get(m.group(1))), - template_str - ) - - def get_matching_group_dict( + def _matching_template_groups( self, - scheme='auto', - palette='auto', + scheme = 'auto', + style = 'auto', **kw_groups, - ) -> dict: + ) -> tuple[dict, list[FilePart]]: ''' - Note that "strictness" doesn't really apply in this setting. In the config - matching setting, setting strict means there's no relaxation to "none," but here, - any "none" group files just help fill any gaps (but are otherwise totally - overwritten, even if matched, by more precise matches). You can match ``nones`` - directly if you want as well. ``get_matching_scripts()`` is similar in this sense. + Find matching template files for provided template groups. + + For template groups other than "scheme" and "style," this method performs a + basic search for matching filenames in the respective group directory. For + example, a KW group like ``font = "mono"`` would look for ``font/mono.toml`` (as + well as the relaxation ``font/none.toml``). These template TOML files are stacked + and ultimately presented to downstream config templates to be filled. Note how + there is no dependence on the scheme during the filename match (e.g., we don't + look for ``font/mono-dark.toml``). + + For "scheme" and "style," we have slightly different behavior, more closely + aligning with the non-template matching. We don't have "scheme" and "style" + template folders, but a single "theme" folder, within which we match template + files just the same as we do for non-template config files. That is, we will look + for files of the format + + ```sh +