11 Commits
0.3.3 ... 0.5.3

27 changed files with 1482 additions and 491 deletions

134
README.md
View File

@@ -1,11 +1,129 @@
# 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.
# Symconf
`symconf` is a CLI tool for managing local application configuration. It implements a
general model that supports dynamically switching/reloading themes for any application,
and provides a basic means of templatizing your config files.
`symconf` also facilitates dynamically setting system and application "themes," symlinking
matching theme config files for registered apps and running config reloading scripts.
## Simple example
Below is a simple example demonstrating two system-wide theme switches:
For
example, the following `symconf` call coordinates a light to dark mode switch
![Simple example](docs/_static/example.gif)
This GIF shows two `symconf` calls, the first of which applies a `gruvbox` dark theme and
the second a dark [`monobiome`][1] variant. Each call (of the form `symconf config -m dark -s
style`) indicates a dark mode preference and a particular color palette that should be
used when populating config file templates. Specifically, in this example, invoking
`symconf` results in the following app-specific config updates:
- **GTK**: reacts to the mode setting and sets `prefer-dark` system-wide, changing general
GTK-responsive applications like Nautilus and Firefox (and subsequently websites that
are responsive to `prefers-color-scheme`)
- **kitty**: theme template is re-generated using the specified palette, and `kitty`
processes are sent a message to live-reload the new config file
- **neovim**: a `vim` theme file is generated from the chosen palette, and running
instances of `neovim` are sent a message to re-source this theme
- **waybar**: bar styles are updated to match the mode setting
- **sway**: the background color and window borders are dynamically set to base palette
colors, and `swaymsg reload` is called
- **fzf**: a palette-dependent theme is re-generated and re-exported
- **rofi**: launcher text and highlight colors are set according to the mode and palette,
applying on next invocation
This example highlights the generality of `symconf`, and so long as an app's config can be
reloaded dynamically, you can use a single `symconf` call to apply themes for an arbitrary
number of apps at once.
# Behavior
`symconf` 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, and app config files can be updated in one place.
App config files can either be concrete (fully-specified) or templates (to be populated by
values conditional on style, e.g., a palette). When `symconf` is executed with a
particular mode preference (dark or light) and a style (any other indicator of thematic
elements, often simply in the form of a palette like `solarized` or `gruvbox`), it
searches for both concrete and template config files that match and symlinks them to
registered locations. When necessary, `symconf` will also match and execute scripts to
reload apps after updating their configuration.
You can find more details on how `symconf`'s matching scheme works in
[Matching](docs/reference/matching).
# Configuring
Before using, you must first set up your config directory to house your config files and
give `symconf` something to act on. See [Configuring](docs/reference/configuring) for
details.
# Installation
The recommended way to install `symconf` is via `pipx`, which is particularly well-suited
for managing Python packages meant to be used as CLI programs. With `pipx` on your system,
you can install with
```sh
pipx install symconf
```
You can also install via `pip`, or clone and install locally.
# Usage
- `-h --help`: print help message
- `-c --config-dir`: set the location of the `symconf` config directory
- `symconf config` is the subcommand used to match and set available config files for
registered applications
* `-a --apps`: comma-separate list of registered apps, or `"*"` (default) to consider
all registered apps.
* `-m --mode`: preferred lightness mode/scheme, either `light`, `dark`, `any`, or
`none`.
* `-s --style`: style indicate, often the name of a color palette, capturing thematic
details in a config file to be matched. `any` or `none` are reserved keywords (see
below).
* `-T --template-vars`: additional groups to use when populating templates, in the form
`<group>=<value>`, where `<group>` is a template group with a folder
`$CONFIG_HOME/groups/<group>/` and `<value>` should correspond to a TOML file in this
folder (i.e., `<value>.toml`).
The keywords `any` and `none` can be used when specifying `--mode`, `--style`, or as a
value in `--template-vars` (and we refer to each of these variables as _factors_ that help
determine a config match):
- `any` will match config files with _any_ value for this factor, preferring config files
with a value `none`, indicating no dependence on the factor. This is the default value
when a factor is left unspecified.
- `none` will match `"none"` directly for a given factor (so no special behavior), but
used to indicate that a config file is independent of the factor. For instance,
```sh
symconf config -m light -s none
```
will match config files that capture the notion of a light mode, but do not depend on or
provide further thematic components such as a color palette.
## Examples
- Set a dark mode for all registered apps, matching any available style/palette component:
```sh
symconf config -m dark
```
- Set `solarized` theme for `kitty` and match any available mode (light or dark):
```sh
symconf config -s solarized -a kitty
```
- Set a dark `gruvbox` theme for multiple apps (but not all):
```sh
symconf config -m dark -s gruvbox -apps="kitty,nvim"
```
- Set a dark `gruvbox` theme for all apps, and attempt to match other template elements:
```sh
symconf config -m dark -s gruvbox -T font=mono window=sharp
```
which would attempt to find and load key-value pairs in the files
`$CONFIG_HOME/groups/font/mono.toml` and `$CONFIG_HOME/groups/window/sharp.toml` to be
used as values when filling templatized config files.
[1]: https://github.com/ologio/monobiome

View File

@@ -1,5 +1,3 @@
- Push scheme generation to `~/.config/autoconf`
- Copy default app registry to the config location
- Formalize the theme spec (JSON) and `autoconf gen` to produce color configs
- Likely need to formalize the `sync.sh` script logic better, possibly associated directly
with registry TOML
- 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

BIN
docs/_static/example.gif vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 MiB

View File

@@ -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/<app-name>/` folder to be
associated with the app `<app-name>`
- 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/<app-name>/` 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.

View File

@@ -0,0 +1,17 @@
# Matching
This file describes the naming and matching scheme employed by `symconf`.
```
~/.config/symconf/
├── app_registry.toml
└── apps/
└── <app>/
   ├── user/ # user managed
│   └── none-none.<config-name>
   ├── generated/ # automatically populated
│   └── none-none.<config-name>
   ├── templates/ # config templates
│   └── none-none.template
   └── call/ # reload scripts
   └── none-none.sh
```

View File

@@ -28,6 +28,9 @@ dependencies = [
"colorama",
]
[project.scripts]
symconf = "symconf.__main__:main"
[project.optional-dependencies]
tests = ["pytest"]
docs = [

View File

@@ -1,5 +1,12 @@
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

View File

@@ -4,32 +4,14 @@ 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(
apps=args.apps,
scheme=args.scheme,
palette=args.palette,
)
cm.install_apps(apps=args.apps)
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).'
)
parser.add_argument(
'-p', '--palette',
required = False,
default = "any",
help = 'Palette name, must match a folder in themes/'
)
parser.add_argument(
'-s', '--scheme',
required = False,
default = "any",
help = 'Preferred lightness scheme, either "light" or "dark".'
'install',
description='Run install scripts for registered applications.'
)
parser.add_argument(
'-a', '--apps',
@@ -39,45 +21,75 @@ 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)
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)
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).'
'update',
description='Run update scripts for registered applications.'
)
parser.add_argument(
'-a', '--app',
required=True,
help='Application target for theme. Supported: ["kitty"]'
'-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 configure_apps(args):
cm = ConfigManager(args.config_dir)
cm.configure_apps(
apps=args.apps,
scheme=args.scheme,
style=args.palette,
)
parser = subparsers.add_parser(
'config',
description='Set config files for registered applications.'
)
parser.add_argument(
'-p', '--palette',
required=True,
help='Palette to use for template mappings. Uses local "theme/<palette>/colors.json".'
'-s', '--style',
required = False,
default = 'any',
help = 'Style indicator (often a color palette) capturing thematic details in '
'a config file'
)
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.'
'-m', '--mode',
required = False,
default = "any",
help = 'Preferred lightness mode/scheme, either "light," "dark," "any," or "none."'
)
parser.add_argument(
'-o', '--output',
default=None,
help='Output file path for theme. If omitted, app\'s default theme output path is used.'
'-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=generate_theme_files)
parser.add_argument(
'-T', '--template-vars',
required = False,
nargs='+',
action=util.KVPair,
help='Groups to use when populating templates, in the form group=value'
)
parser.set_defaults(func=configure_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,14 +100,18 @@ 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)
if __name__ == '__main__':
def main():
args = parser.parse_args()
if 'func' in args:
args.func(args)
else:
parser.print_help()
if __name__ == '__main__':
main()

View File

@@ -1,14 +1,37 @@
'''
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
<config_path_name> -> <config_dir>/apps/<app_name>/<subdir>/<palette>-<scheme>.<config_path_name>
```
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 json
import inspect
import tomllib
import argparse
import subprocess
from pathlib import Path
from colorama import Fore, Back, Style
from symconf import util
from symconf.util import printc, color_text
from symconf.runner import Runner
from symconf.template import FileTemplate, TOMLTemplate
from symconf.matching import Matcher, FilePart
class ConfigManager:
@@ -33,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).
'''
@@ -62,204 +87,284 @@ class ConfigManager:
)
def _check_registry(self):
'''
Check the existence and format of the registry file
``<config_dir>/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_scheme(self, scheme):
# if scheme == 'auto':
# os_cmd_groups = {
# 'Linux': (
# "gsettings get org.gnome.desktop.interface color-scheme",
# lambda r: r.split('-')[1][:-1],
# ),
# 'Darwin': (),
# }
def _resolve_group(self, group, value='auto'):
'''
Resolve group inputs to concrete values.
# osname = os.uname().sysname
# os_group = os_cmd_groups.get(osname, [])
# for cmd in cmd_list:
# subprocess.check_call(cmd.format(scheme=scheme).split())
# return scheme
if scheme == 'auto':
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 scheme
return value
def _resolve_palette(self, palette):
if palette == 'auto':
return 'any'
return palette
def app_config_map(self, app_name) -> dict[str, Path]:
'''
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
<config_path_name> -> <config_dir>/apps/<app_name>/<subdir>/<palette>-<scheme>.<config_path_name>
```
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).
'''
# first look in "generated", then overwrite with "user"
file_map = {}
app_dir = Path(self.apps_dir, app_name)
for subdir in ['generated', 'user']:
subdir_path = Path(app_dir, subdir)
if not subdir_path.is_dir():
continue
for conf_file in subdir_path.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')
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,
def _symlink_paths(
self,
to_symlink: list[tuple[Path, Path]],
):
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'),
]
'''
Symlink paths safely from target paths to internal config paths
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.
Parameters:
to_symlink: path pairs to symlink, from target (external) path to source
(internal) path
'''
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
# 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,
):
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 palette_prefix, scheme_prefix in prefix_order:
for theme_part, conf_part, pathname in file_parts:
theme_split = theme_part.split('-')
palette_part, scheme_part = '-'.join(theme_split[:-1]), 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))
def _matching_template_groups(
self,
scheme = 'auto',
style = 'auto',
**kw_groups,
) -> tuple[dict, list[FilePart]]:
'''
Find matching template files for provided template groups.
return ordered_matches
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
<style>-<scheme>.toml
```
The only difference is that, while ``style`` can still include arbitrary style
variants, it *must* have the form
```sh
<variant-1>-...-<variant-N>-<palette>
```
if you want to match a ``palette`` template. Palettes are like regular template
groups, and should be placed in their own template folder. But when applying those
palette colors, they almost always need to be coupled with a scheme setting (e.g.,
"solarized-dark"). This is the one place where the templating system allows
"intermediate templates:" raw palette colors can fill theme templates, which then
fill user config templates.
So in summary: palette files can be used to populate theme templates by providing a
style string that matches the format ``<variant>-<palette>``. The ``<palette>``
will be extracted and used to match filenames in the palette template folder. The
term ``<variant>-<palette>-<scheme>`` will be used to match templates in the theme
folder, where ``<variant>-<palette> = <style>`` and ``<scheme>`` are independently
specifiable with supported for ``auto``, ``none``, etc.
Note that "strictness" doesn't really apply in this setting. In the non-template
config matching setting, setting strict means there's no relaxation to "none," but
here, any "none" group template 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 by specifying that directly.
``get_matching_scripts()`` is similar in this sense.
'''
scheme = self._resolve_group('scheme', scheme)
style = self._resolve_group('style', style)
groups = {
k : self._resolve_group(k, v)
for k, v in kw_groups.items()
}
if not self.group_dir.exists():
return {}, []
# palette lookup will behave like other groups; strip it out of the `style` string
# and it to the keyword groups to be searched regularly (but only if the palette
# group exists)
if Path(self.group_dir, 'palette').exists():
palette = style.split('-')[-1]
groups['palette'] = palette
# handle individual groups (not part of joint style-scheme)
group_matches = {}
for fkey, fval in groups.items():
key_group_dir = Path(self.group_dir, fkey)
if not key_group_dir.exists():
print(f'Group directory "{fkey}" doesn\'t exist, skipping')
continue
# mirror matching scheme: 1) prefix order, 2) full enumeration, 3) select
# best, 4) make unique, 5) ordered relaxation
stem_map = {path.stem : path for path in key_group_dir.iterdir()}
# 1) establish prefix order
if fval == 'any':
prefix_order = [fval, 'none']
else:
prefix_order = ['none', fval]
# 2) fully enumerate matches, including "any"
matches = []
for prefix in prefix_order:
for stem in stem_map:
if prefix == stem or prefix == 'any':
matches.append(stem)
if not matches:
# no matches for group, skip
continue
# 3) select best matches; done in a loop to smooth the logic, else we'd need
# to check if the last match is "none," and if not, find out if it was
# available. This alone makes the following loop more easy to follow: walk
# through full enumeration, and if it's the target match or "none," take the
# file, nicely handling the fact those may both be the same.
#
# also 4) uniqueness happening here
match_dict = {}
target = matches[-1] # select best based on order, make new target
for stem in matches:
if stem == target or stem == 'none':
match_dict[stem] = stem_map[stem]
group_matches[fkey] = list(match_dict.values())
# first handle scheme maps; matching palette files should already be found in the
# regular group matching process. This is the one template group that gets nested
# treatment
palette_dict = TOMLTemplate.stack_toml(group_matches.get('palette', []))
# then palette-scheme groups (require 2-combo logic)
theme_matches = []
theme_group_dir = Path(self.group_dir, 'theme')
if theme_group_dir.exists():
theme_matches = self.matcher.match_paths(
theme_group_dir.iterdir(), # match files in groups/theme/
self.matcher.prefix_order(scheme, style) # reg non-template order
)
# 5) final match relaxation
relaxed_theme_matches = self.matcher.relaxed_match(theme_matches)
theme_dict = {}
for file_part in relaxed_theme_matches:
toml_dict = TOMLTemplate(file_part.path).fill(palette_dict)
theme_dict = util.deep_update(theme_dict, toml_dict)
template_dict = {
group : TOMLTemplate.stack_toml(ordered_matches)
for group, ordered_matches in group_matches.items()
}
template_dict['theme'] = theme_dict
return template_dict, relaxed_theme_matches
def get_matching_configs(
self,
app_name,
scheme='auto',
palette='auto',
strict=False,
) -> dict[str, Path]:
scheme = 'auto',
style = 'auto',
strict = False,
) -> dict[str, FilePart]:
'''
Get app config files that match the provided scheme and palette.
Get user-provided app config files that match the provided scheme and style
specifications.
Unique config file path names are written to the file map in order of specificity.
All config files follow the naming scheme ``<palette>-<scheme>.<path-name>``,
where ``<palette>-<scheme>`` is the "theme part" and ``<path-name>`` is the "conf
All config files follow the naming scheme ``<style>-<scheme>.<path-name>``,
where ``<style>-<scheme>`` is the "theme part" and ``<path-name>`` is the "conf
part." For those config files with the same "conf part," only the entry with the
most specific "theme part" will be stored. By "most specific," we mean those
entries with the fewest possible components named ``none``, with ties broken in
favor of a more specific ``palette`` (the only "tie" really possible here is when
``none-<scheme>`` and ``<palette>-none`` are both available, in which case the latter
favor of a more specific ``style`` (the only "tie" really possible here is when
``none-<scheme>`` and ``<style>-none`` are both available, in which case the latter
will overwrite the former).
.. admonition: Edge cases
@@ -267,16 +372,16 @@ class ConfigManager:
There are a few quirks to this matching scheme that yield potentially
unintuitive results. As a recap:
- The "theme part" of a config file name includes both a palette and a scheme
component. Either of those parts may be "none," which simply indicates that
that particular file does not attempt to change that factor. "none-light,"
for instance, might simply set a light background, having no effect on other
theme settings.
- Non-keyword queries for scheme and palette will always be matched exactly.
- The "theme part" of a config file name includes both a style (palette and
more) and a scheme component. Either of those parts may be "none," which
simply indicates that that particular file does not attempt to change that
factor. "none-light," for instance, might simply set a light background,
having no effect on other theme settings.
- Non-keyword queries for scheme and style will always be matched exactly.
However, if an exact match is not available, we also look for "none" in each
component's place. For example, if we wanted to set "solarized-light" but
only "none-light" was available, it would still be set because we can still
satisfy the desire scheme (light). The same goes for the palette
satisfy the desire scheme (light). The same goes for the style
specification, and if neither match, "none-none" will always be matched if
available. Note that if "none" is specified exactly, it will be matched
exactly, just like any other value.
@@ -284,7 +389,7 @@ class ConfigManager:
we're okay to match any file's text for that part. For example, if I have
two config files ``"p1-dark"`` and ``"p2-dark"``, the query for ``("any",
"dark")`` would suggest I'd like the dark scheme but am okay with either
palette.
style.
It's under the "any" keyword where possibly counter-intuitive results may come
about. Specifying "any" does not change the mechanism that seeks to optionally
@@ -293,9 +398,9 @@ class ConfigManager:
mode). If I query for ``("any", "dark")``, ``red-none`` will be matched
(supposing there are no more direct matches available). Because we don't a
match specifically for the scheme "dark," it gets relaxed to "none." But we
indicated we're okay to match any palette. So despite asking for a config that
sets a dark scheme and not caring about the palette, we end up with a config
that explicitly does nothing about the scheme but sets a particular palette.
indicated we're okay to match any style. So despite asking for a config that
sets a dark scheme and not caring about the style, we end up with a config
that explicitly does nothing about the scheme but sets a particular style.
This matching process is still consistent with what we expect the keywords to
do, it just slightly muddies the waters with regard to what can be matched
(mostly due to the amount that's happening under the hood here).
@@ -307,40 +412,72 @@ class ConfigManager:
Also: when "any" is used for a component, options with "none" are prioritized,
allowing "any" to be as flexible and unassuming as possible (only matching a
random specific config among the options if there is no "none" available).
Returns:
Dictionary
'''
app_dir = Path(self.apps_dir, app_name)
user_app_dir = Path(self.apps_dir, app_name, 'user')
scheme = self._resolve_scheme(scheme)
palette = self._resolve_palette(palette)
app_config_map = self.app_config_map(app_name)
ordered_matches = self.match_pathnames(
app_config_map,
scheme,
palette,
strict=strict,
paths = []
if user_app_dir.is_dir():
paths = user_app_dir.iterdir()
# 1) establish prefix order
prefix_order = self.matcher.prefix_order(
self._resolve_group('scheme', scheme),
self._resolve_group('style', style),
strict=strict
)
matching_file_map = {}
for conf_part, theme_part, pathname in ordered_matches:
matching_file_map[conf_part] = app_config_map[pathname]
# 2) match enumeration
ordered_matches = self.matcher.match_paths(paths, prefix_order)
# 3) make unique (by pathname)
matching_file_map = {
file_part.conf : file_part
for file_part in ordered_matches
}
return matching_file_map
def get_matching_templates(
self,
app_name,
scheme='auto',
style='auto',
**kw_groups,
) -> tuple[dict[str, Path], dict, list[FilePart], int]:
template_dict, theme_matches = self._matching_template_groups(
scheme=scheme,
style=style,
**kw_groups,
)
max_idx = 0
if theme_matches:
max_idx = max([fp.index for fp in theme_matches])
template_map = {}
template_dir = Path(self.apps_dir, app_name, 'templates')
if template_dir.is_dir():
for template_file in template_dir.iterdir():
template_map[template_file.name] = template_file
return template_map, template_dict, theme_matches, max_idx
def get_matching_scripts(
self,
app_name,
scheme='any',
palette='any',
):
style='any',
) -> list[FilePart]:
'''
Execute matching scripts in the app's ``call/`` directory.
Scripts need to be placed in
Scripts need to be placed in
```sh
<config_dir>/apps/<app_name>/call/<palette>-<scheme>.sh
<config_dir>/apps/<app_name>/call/<style>-<scheme>.sh
```
and are matched using the same heuristic employed by config file symlinking
@@ -354,34 +491,34 @@ class ConfigManager:
'''
app_dir = Path(self.apps_dir, app_name)
call_dir = Path(app_dir, 'call')
if not call_dir.is_dir():
return
return []
prefix_order = [
('none' , 'none'),
('none' , scheme),
(palette , 'none'),
(palette , scheme),
('none', 'none'),
('none', scheme),
(style, 'none'),
(style, scheme),
]
pathnames = [path.name for path in call_dir.iterdir()]
ordered_matches = self.match_pathnames(
pathnames,
scheme,
palette,
script_matches = self.matcher.match_paths(
call_dir.iterdir(),
prefix_order=prefix_order
)
relaxed_matches = self.matcher.relaxed_match(script_matches)
# flip list to execute by decreasing specificity
return list(dict.fromkeys(map(lambda x:Path(call_dir, x[2]), ordered_matches)))[::-1]
return relaxed_matches[::-1]
def update_app_config(
self,
app_name,
app_settings = None,
scheme = 'any',
palette = 'any',
app_name : str,
app_settings : dict = None,
scheme : str = 'any',
style : str = 'any',
strict : bool = False,
**kw_groups,
):
'''
Perform full app config update process, applying symlinks and running scripts.
@@ -395,6 +532,53 @@ class ConfigManager:
Note: symlinks point **from** the target location **to** the known internal config
file; can be a little confusing.
.. admonition:: Logic overview
This method is the center point of the ConfigManager class. It unifies the
user and template matching, file generation, setting of symlinks, and running
of scripts. At a high level,
1. An app name (e.g., kitty), app settings (e.g., a ``config_dir`` or
``config_map``), scheme (e.g., "dark"), and style (e.g., "soft-gruvbox")
2. Get matching user config files via ``get_matching_configs()``
3. Get matching template config files and the aggregate template dict via
``get_matching_templates()``
4. Interleave the two result sets by pathname and match quality. Template
matches are preferred in the case of tied scores. This resolves any
pathname clashes across matching files.
This is a particularly important step. It compares concrete config names
explicitly provided by the user (e.g., ``soft-gruvbox-dark.kitty.conf``)
with named TOML files in a group directory (e.g,.
``theme/soft-gruvbox-dark.toml``). We have to determine whether the
available templates constitute a better match than the best user option,
which is done by comparing the level in the prefix order (the index)
where the match takes place.
Templates are generally more flexible, and other keywords may also provide
a matching template group (e.g., ``-T font=mono`` to match some
font-specific settings). When the match is otherwise equally good (e.g.,
both style and scheme match directly), we prefer the template due to its
general portability and likelihood of being more up-to-date. We also don't
explicitly use the fact auxiliary template groups might be matched by the
user's input: we only compare the user and template configs on the basis of
the quality of the style-scheme match. This effectively means additional
template groups (e.g., font) don't "count" if the basis style-scheme
doesn't win over a user config file. There could be an arbitrary number of
other template group matches, but they don't contribute to the match
quality. For instance, a concrete user config ``solarized-dark.kitty.conf``
will be selected over ``solarized-none.toml`` plus 10 other matching theme
elements if the user asked for ``-s dark -t solarized``.
5. For those template matches, fill/generate the template file and place it in
the app's ``generated/`` directory.
Parameters:
app_name: name of the app whose config files should be updated
app_settings: dict of app settings (i.e., ``config_dir`` or ``config_map``)
scheme: scheme spec
style: style spec
strict: whether to match ``scheme`` and ``style`` strictly
'''
if app_settings is None:
app_settings = self.app_registry.get(app_name, {})
@@ -403,94 +587,106 @@ class ConfigManager:
print(f'App "{app_name}" incorrectly configured, skipping')
return
to_symlink: list[tuple[Path, Path]] = []
file_map = self.get_matching_configs(
# match both user configs and templates
# -> "*_map" are dicts from config pathnames to FilePart / Paths
config_map = self.get_matching_configs(
app_name,
scheme=scheme,
palette=palette,
style=style,
strict=strict,
)
template_map, template_dict, theme_matches, tidx = self.get_matching_templates(
app_name,
scheme=scheme,
style=style,
**kw_groups
)
# create "generated" directory for the app
generated_path = Path(self.apps_dir, app_name, 'generated')
generated_path.mkdir(parents=True, exist_ok=True)
# track selected configs with a pathname -> fullpath map
final_config_map = {}
# tracker for template configs that were generated
generated_config = []
# interleave user and template matches
for pathname, full_path in template_map.items():
if pathname in config_map and config_map[pathname].index > tidx:
final_config_map[pathname] = config_map[pathname].path
else:
config_path = Path(generated_path, pathname)
config_path.write_text(
FileTemplate(full_path).fill(template_dict)
)
final_config_map[pathname] = config_path
generated_config.append(pathname)
# fill in any config matches not added to final_config_map above
for pathname, file_part in config_map.items():
if pathname not in final_config_map:
final_config_map[pathname] = file_part.path
# prepare symlinks (inverse loop and conditional order is sloppier)
to_symlink: list[tuple[Path, Path]] = []
if 'config_dir' in app_settings:
for config_tail, full_path in file_map.items():
config_dir = util.absolute_path(app_settings['config_dir'])
for ext_pathname, int_fullpath in final_config_map.items():
ext_fullpath = Path(config_dir, ext_pathname)
to_symlink.append((
util.absolute_path(Path(app_settings['config_dir'], config_tail)), # point from real config dir
full_path, # to internal config location
ext_fullpath, # point from external config dir
int_fullpath, # to internal config location
))
elif 'config_map' in app_settings:
for config_tail, full_path in file_map.items():
# app's config map points config tails to absolute paths
if config_tail in app_settings['config_map']:
for ext_pathname, int_fullpath in final_config_map.items():
# app's config map points config pathnames to absolute paths
if ext_pathname in app_settings['config_map']:
ext_fullpath = util.absolute_path(app_settings['config_map'][ext_pathname])
to_symlink.append((
abs_pat(Path(app_settings['config_map'][config_tail])), # point from real config path
full_path, # to internal config location
ext_fullpath, # point from external config path
int_fullpath, # to internal config location
))
print('├─ ' + Fore.YELLOW + f'{app_name} :: matched {len(to_symlink)} config files')
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
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():
print(
f'Symlink target "{from_path}" exists and isn\'t a symlink, NOT overwriting;' \
+ ' please first manually remove this file so a symlink can be set.'
)
links_fail.append((from_path, to_path))
continue
else:
# 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)
#print(f'Linking [{from_path}] -> [{to_path}]')
from_path.symlink_to(to_path)
links_succ.append((from_path, to_path))
# link report
for from_p, to_p in links_succ:
from_p = from_p
to_p = to_p.relative_to(self.config_dir)
print(Fore.GREEN + f'│ > linked {from_p} -> {to_p}')
for from_p, to_p in links_fail:
from_p = from_p
to_p = to_p.relative_to(self.config_dir)
print(Fore.RED + f'│ > failed to link {from_p} -> {to_p}')
# run matching scripts for app-specific reload
script_list = self.get_matching_scripts(
app_name,
scheme=scheme,
palette=palette,
style=style,
)
script_list = list(map(lambda f:f.path, script_list))
# print match messages
num_links = len(to_symlink)
num_scripts = len(script_list)
print(
color_text("├─", Fore.BLUE),
f'{app_name} :: matched ({num_links}) config files and ({num_scripts}) scripts'
)
for script in script_list:
print(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','\n')
print(
Fore.BLUE + Style.DIM \
+ f'│ > captured script output "{fmt_output}"' \
+ Style.RESET_ALL
rel_theme_matches = ' < '.join([
str(fp.path.relative_to(self.group_dir))
for fp in theme_matches
])
for pathname in generated_config:
print(
color_text("", Fore.BLUE),
color_text(
f' > generating config "{pathname}" from [{rel_theme_matches}]',
Style.DIM
)
)
def update_apps(
self._symlink_paths(to_symlink)
self.runner.run_many(script_list)
def configure_apps(
self,
apps: str | list[str] = '*',
scheme = 'any',
palette = 'any',
apps : str | list[str] = '*',
scheme : str = 'any',
style : str = 'any',
strict : bool = False,
**kw_groups,
):
if apps == '*':
# get all registered apps
@@ -503,15 +699,68 @@ class ConfigManager:
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)
print(' > palette :: ' + Fore.YELLOW + f'{palette}' + Style.RESET_ALL)
print(' > scheme :: ' + Fore.YELLOW + f'{scheme}\n' + Style.RESET_ALL)
print(f'> symconf parameters: ')
print(f' > registered apps :: {color_text(app_list, Fore.YELLOW)}')
print(f' > style :: {color_text(style, Fore.YELLOW)}')
print(f' > scheme :: {color_text(scheme, Fore.YELLOW)}\n')
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],
scheme=scheme,
palette=palette,
style=style,
strict=False,
**kw_groups,
)
def _app_action(
self,
script_pathname,
apps: str | list[str] = '*',
):
'''
Execute a static script-based action for a provided set of apps.
Mostly a helper method for install and update actions, calling a static script
name under each app's directory.
'''
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(
f'> symconf parameters: '
f' > registered apps :: {color_text(app_list, Fore.YELLOW)}'
)
for app_name in app_list:
target_script = Path(self.apps_dir, app_name, script_pathname)
if not target_script.exists():
continue
self.runner.run_script(target_script)
def install_apps(
self,
apps: str | list[str] = '*',
):
self._app_action('install.sh', apps)
def update_apps(
self,
apps: str | list[str] = '*',
):
self._app_action('update.sh', apps)

251
symconf/matching.py Normal file
View File

@@ -0,0 +1,251 @@
'''
Top-level definitions
Config files are expected to have names matching the following spec:
<style>-<scheme>.<config_pathname>
- ``config_pathname``: refers to a concrete filename, typically that which is expected by
the target app (e.g., ``kitty.conf``). In the context of ``config_map`` in the registry,
however, it merely serves as an identifier, as it can be mapped onto any path.
- ``scheme``: indicates the lightness mode ("light" or "dark")
- ``style``: general identifier capturing the stylizations applied to the config file.
This is typically of the form ``<variant>-<palette>``, i.e., including a reference to a
particular color palette.
For example
```sh
soft-gruvbox-dark.kitty.conf
```
gets mapped to
```sh
style -> "soft-gruvbox"
scheme -> "dark"
pathname -> "kitty.conf"
```
'''
from pathlib import Path
from symconf import util
class FilePart:
def __init__(self, path: str | Path):
self.path = util.absolute_path(path)
self.pathname = self.path.name
parts = str(self.pathname).split('.')
if len(parts) < 2:
raise ValueError(f'Filename "{pathname}" incorrectly formatted, ignoring')
self.theme = parts[0]
self.conf = '.'.join(parts[1:])
theme_split = self.theme.split('-')
self.scheme = theme_split[-1]
self.style = '-'.join(theme_split[:-1])
self.index = -1
def set_index(self, idx: int):
self.index = idx
class Matcher:
def get_file_parts(
self,
paths: list[str | Path],
) -> list[FilePart]:
'''
Split pathnames into parts for matching.
Pathnames should be of the format
```sh
<style>-<scheme>.<config_pathname>
```
where ``style`` is typically itself of the form ``<variant>-<palette>``.
'''
file_parts = []
for path in paths:
try:
config_file = FilePart(path)
file_parts.append(config_file)
except ValueError as e:
print(f'Filename "{pathname}" incorrectly formatted, ignoring')
return file_parts
def prefix_order(
self,
scheme,
style,
strict=False,
) -> list[tuple[str, str]]:
'''
Determine the order of concrete config pathname parts to match, given the
``scheme`` and ``style`` inputs.
There is a unique preferred match order when ``style``, ``scheme``, both, or none
are ``any``. In general, when ``any`` is provided for a given factor, it is
best matched by a config file that expresses indifference under that factor.
'''
# explicit cases are the most easily managed here, even if a little redundant
if strict:
theme_order = [
(style, scheme),
]
else:
# inverse order of match relaxation; intention being to overwrite with
# results from increasingly relevant groups given the conditions
if style == 'any' and scheme == 'any':
# prefer both be "none", with preference for specific scheme
theme_order = [
(style , scheme),
(style , 'none'),
('none' , scheme),
('none' , 'none'),
]
elif style == 'any':
# prefer style to be "none", then specific, then relax specific scheme
# to "none"
theme_order = [
(style , 'none'),
('none' , 'none'),
(style , scheme),
('none' , scheme),
]
elif scheme == 'any':
# prefer scheme to be "none", then specific, then relax specific style
# to "none"
theme_order = [
('none' , scheme),
('none' , 'none'),
(style , scheme),
(style , 'none'),
]
else:
# neither component is any; prefer most specific
theme_order = [
('none' , 'none'),
('none' , scheme),
(style , 'none'),
(style , scheme),
]
return theme_order
def match_paths(
self,
paths: list[str | Path],
prefix_order: list[tuple[str, str]],
) -> list[FilePart]:
'''
Find and return FilePart matches according to the provided prefix order.
The prefix order specifies all valid style-scheme combos that can be considered as
"consistent" with some user input (and is computed external to this method). For
example, it could be
```py
[
('none', 'none')
('none', 'dark')
]
```
indicating that either ``none-none.<config>`` or ``none-dark.<config>`` would be
considered matching pathnames, with the latter being preferred.
This method exists because we need a way to allow any of the combos in the prefix
order to match the candidate files. We don't know a priori how good of a match
will be available, so we consider each file for each of the prefixes, and take the
latest/best match for each unique config pathname (allowing for a "soft" match).
.. admonition: Checking for matches
When thinking about how best to structure this method, it initially felt like
indexing factors of the FileParts would make the most sense, preventing the
inner loop that needs to inspect each FilePart for each element of the prefix
order. But indexing the file parts and checking against prefixes isn't so
straightforward, as we'd still need to check matches by factor. For instance,
if we index by style-scheme, either are allowed to be "any," so we'd need to
check for the 4 valid combos and join the matching lists. If we index by both
factors individually, we may have several files associated with a given key,
and then need to coordinate the checks across both to ensure they belong to
the same file.
In any case, you should be able to do this in a way that's a bit more
efficient, but the loop and the simple conditionals is just much simpler to
follow. We're also talking about at most 10s of files, so it really doesn't
matter.
Parameters:
pathnames:
scheme:
style:
prefix_order:
strict:
'''
file_parts = self.get_file_parts(paths)
ordered_matches = []
for i, (style_prefix, scheme_prefix) in enumerate(prefix_order):
for fp in file_parts:
style_match = style_prefix == fp.style or style_prefix == 'any'
scheme_match = scheme_prefix == fp.scheme or scheme_prefix == 'any'
if style_match and scheme_match:
fp.set_index(i+1)
ordered_matches.append(fp)
return ordered_matches
def relaxed_match(
self,
match_list: list[FilePart]
) -> list[FilePart]:
'''
Isolate the best match in a match list and find its relaxed variants.
This method allows us to use the ``match_paths()`` method for matching templates
rather than direct user config files. In the latter case, we want to symlink the
single best config file match for each stem, across all stems with matching
prefixes (e.g., ``none-dark.config.a`` and ``solarized-dark.config.b`` have two
separate stems with prefixes that could match ``scheme=dark, style=any`` query).
We can find these files by just indexing the ``match_path`` outputs (i.e., all
matches) by config pathname and taking the one that appears latest (under the
prefix order) for each unique value.
In the template matching case, we want only a single best file match, period
(there's really no notion of "config stems," it's just the prefixes). Once that
match has been found, we can then "relax" either the scheme or style (or both) to
``none``, and if the corresponding files exist, we use those as parts of the
template keys. For example, if we match ``solarized-dark.toml``, we would also
consider the values in ``none-dark.toml`` if available. The TOML values that are
defined in the most specific (i.e., better under the prefix order) match are
loaded "on top of" those less specific matches, overwriting keys when there's a
conflict. ``none-dark.toml``, for instance, might define a general dark scheme
background color, but a more specific definition in ``solarized-dark.toml`` would
take precedent. These TOML files would be stacked before using the resulting
dictionary to populate config templates.
'''
if not match_list:
return []
relaxed_map = {}
match = match_list[-1]
for fp in match_list:
style_match = fp.style == match.style or fp.style == 'none'
scheme_match = fp.scheme == match.scheme or fp.scheme == 'none'
if style_match and scheme_match:
relaxed_map[fp.pathname] = fp
return list(relaxed_map.values())

94
symconf/reader.py Normal file
View File

@@ -0,0 +1,94 @@
import copy
import pprint
import tomllib
import hashlib
from typing import Any
from pathlib import Path
from symconf.util import deep_update
class DictReader:
def __init__(self, toml_path=None):
self._config = {}
self.toml_path = toml_path
if toml_path is not None:
self._config = self._load_toml(toml_path)
def __str__(self):
return pprint.pformat(self._config, indent=4)
@staticmethod
def _load_toml(toml_path) -> dict[str, Any]:
return tomllib.loads(Path(toml_path).read_text())
@classmethod
def from_dict(cls, config_dict):
new_instance = cls()
new_instance._config = copy.deepcopy(config_dict)
return new_instance
def update(self, config, in_place=False):
new_config = deep_update(self._config, config._config)
if in_place:
self._config = new_config
return self
return self.from_dict(new_config)
def copy(self):
return self.from_dict(copy.deepcopy(self._config))
def get_subconfig(self, key): pass
def get(self, key, default=None):
keys = key.split('.')
subconfig = self._config
for subkey in keys[:-1]:
subconfig = subconfig.get(subkey)
if type(subconfig) is not dict:
return default
return subconfig.get(keys[-1], default)
def set(self, key, value):
keys = key.split('.')
subconfig = self._config
for subkey in keys[:-1]:
if subkey in subconfig:
subconfig = subconfig[subkey]
if type(subconfig) is not dict:
logger.debug(
'Attempting to set nested key with an existing non-dict parent'
)
return False
continue
subdict = {}
subconfig[subkey] = subdict
subconfig = subdict
subconfig.update({ keys[-1]: value })
return True
def generate_hash(self, exclude_keys=None):
inst_copy = self.copy()
if exclude_keys is not None:
for key in exclude_keys:
inst_copy.set(key, None)
items = inst_copy._config.items()
# create hash from config options
config_str = str(sorted(items))
return hashlib.md5(config_str.encode()).hexdigest()

61
symconf/runner.py Normal file
View File

@@ -0,0 +1,61 @@
import stat
import subprocess
from pathlib import Path
from colorama import Fore, Back, Style
from symconf.util import printc, color_text
class Runner:
def run_script(
self,
script: str | Path,
):
script_path = Path(script)
if script_path.stat().st_mode & stat.S_IXUSR == 0:
print(
color_text("", Fore.BLUE),
color_text(
f' > script "{script_path.name}" missing execute permissions, skipping',
Fore.RED + Style.DIM
)
)
return
print(
color_text("", Fore.BLUE),
color_text(
f' > running script "{script_path.name}"',
Fore.BLUE
)
)
output = subprocess.check_output(str(script_path), shell=True)
if output:
fmt_output = output.decode().strip().replace(
'\n',
f'\n{Fore.BLUE}{Style.NORMAL}{Style.DIM} '
)
print(
color_text("", Fore.BLUE),
color_text(
f' captured script output "{fmt_output}"',
Fore.BLUE + Style.DIM
)
)
return output
def run_many(
self,
script_list: list[str | Path],
):
outputs = []
for script in script_list:
output = self.run_script(script)
outputs.append(output)
return outputs

70
symconf/template.py Normal file
View File

@@ -0,0 +1,70 @@
import re
import tomllib
from pathlib import Path
from symconf import util
from symconf.reader import DictReader
class Template:
def __init__(
self,
template_str : str,
pattern : str = r'f{{(\S+?)}}',
):
self.template_str = template_str
self.pattern = pattern
def fill(
self,
template_dict : dict,
) -> str:
dr = DictReader.from_dict(template_dict)
return re.sub(
self.pattern,
lambda m: str(dr.get(m.group(1))),
self.template_str
)
class FileTemplate(Template):
def __init__(
self,
path : Path,
pattern : str = r'f{{(\S+)}}',
):
super().__init__(
path.open('r').read(),
pattern=pattern
)
class TOMLTemplate(FileTemplate):
def __init__(
self,
toml_path : Path,
pattern : str = r'f{{(\S+)}}',
):
super().__init__(
toml_path,
pattern=pattern
)
def fill(
self,
template_dict : dict,
) -> str:
filled_template = super().fill(template_dict)
toml_dict = tomllib.loads(filled_template)
return toml_dict
@staticmethod
def stack_toml(
path_list: list[Path]
) -> dict:
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

View File

@@ -1,9 +1,80 @@
import re
import argparse
from pathlib import Path
from xdg import BaseDirectory
from xdg import BaseDirectory
from colorama import Fore, Back, Style
from colorama.ansi import AnsiFore, AnsiBack, AnsiStyle
def color_text(text, *colorama_args):
'''
Colorama text helper function
Note: we attempt to preserve expected nested behavior by only resetting the groups
(Fore, Back, Style) affected the styles passed in. This works when an outer call is
changing styles in one group, and an inner call is changing styles in another, but
_not_ when affected groups overlap.
For example, if an outer call is setting the foreground color (e.g., ``Fore.GREEN``),
nested calls on the text being passed into the function can modify and reset the
background or style with affecting the foreground. The primary use case here is
styling a group of text a single color, but applying ``BRIGHT`` or ``DIM`` styles only
to some text elements within. If we didn't reset by group, the outer coloration
request will be "canceled out" as soon as the first inner call is made (since the
unconditional behavior just employs ``Style.RESET_ALL``).
'''
# reverse map colorama Ansi codes
resets = []
for carg in colorama_args:
match = re.match(r'.*\[(\d+)m', carg)
if match:
intv = int(match.group(1))
if (intv >= 30 and intv <= 39) or (intv >= 90 and intv <= 97):
resets.append(Fore.RESET)
elif (intv >= 40 and intv <= 49) or (intv >= 100 and intv <= 107):
resets.append(Back.RESET)
elif (intv >= 0 and intv <= 2) or intv == 22:
resets.append(Style.NORMAL)
return f"{''.join(colorama_args)}{text}{''.join(resets)}"
def printc(text, *colorama_args):
print(color_text(text, *colorama_args))
def absolute_path(path: str | Path) -> Path:
return Path(path).expanduser().absolute()
def xdg_config_path():
return Path(BaseDirectory.save_config_path('symconf'))
def to_tilde_path(path: Path) -> Path:
'''
Abbreviate an absolute path by replacing HOME with "~", if applicable.
'''
try:
return Path(f"~/{path.relative_to(Path.home())}")
except ValueError:
return path
def deep_update(mapping: dict, *updating_mappings: dict) -> dict:
'''Code adapted from pydantic'''
updated_mapping = mapping.copy()
for updating_mapping in updating_mappings:
for k, v in updating_mapping.items():
if k in updated_mapping and isinstance(updated_mapping[k], dict) and isinstance(v, dict):
updated_mapping[k] = deep_update(updated_mapping[k], v)
else:
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)

View File

@@ -0,0 +1,2 @@
[app.test]
config_dir = 'sym_tgt/test'

View File

@@ -1 +0,0 @@
gsettings set org.gnome.desktop.interface color-scheme 'prefer-dark'

View File

@@ -1 +0,0 @@
gsettings set org.gnome.desktop.interface color-scheme 'prefer-light'

View File

@@ -1 +1 @@
echo "> none-light ran"
echo "none-light ran"

View File

@@ -1 +1 @@
echo "> none-none ran"
echo "none-none ran"

View File

@@ -0,0 +1 @@
base = "aaa"

View File

@@ -0,0 +1 @@
concrete = "zzz"

View File

@@ -1,131 +0,0 @@
from pathlib import Path
from symconf import ConfigManager
config_dir = Path(
__file__, '..', 'test-config-dir/'
).resolve()
cm = ConfigManager(config_dir)
def test_config_map():
file_map = cm.app_config_map('test')
# from user
assert 'none-none.aaa' in file_map
assert 'none-light.aaa' in file_map
assert 'test-dark.bbb' in file_map
assert 'test-light.ccc' in file_map
# from generated
assert 'test-none.aaa' in file_map
def test_matching_configs_exact():
'''
Test matching exact palette and scheme. Given strict mode not set (allowing relaxation
to "none"), the order of matching should be
1. (none, none) :: none-none.aaa
2. (none, scheme) :: none-light.aaa
3. (palette, none) :: test-none.aaa & test-none.ddd
4. (palette, scheme) :: test-light.ccc
Yielding "test-none.aaa", "test-light.ccc", "test-none.ddd" (unique only on path name).
'''
any_light = cm.get_matching_configs(
'test',
palette='test',
scheme='light',
)
assert len(any_light) == 3
assert any_light['aaa'].name == 'test-none.aaa'
assert any_light['ccc'].name == 'test-light.ccc'
assert any_light['ddd'].name == 'test-none.ddd'
def test_matching_configs_any_palette():
'''
Test matching exact palette and scheme. Given strict mode not set (allowing relaxation
to "none"), the order of matching should be
1. (palette, none) :: test-none.aaa & test-none.ddd & none-none.aaa
2. (none, none) :: none-none.aaa
3. (palette, scheme) :: test-dark.bbb
4. (none, scheme) :: (nothing)
Yielding "none-none.aaa" (should always overwrite "test-none.aaa" due to "any"'s
preference for non-specific matches, i.e., "none"s), "test-none.ddd", "test-dark.bbb"
(unique only on path name).
'''
any_dark = cm.get_matching_configs(
'test',
palette='any',
scheme='dark',
)
assert len(any_dark) == 3
assert any_dark['aaa'].name == 'none-none.aaa'
assert any_dark['bbb'].name == 'test-dark.bbb'
assert any_dark['ddd'].name == 'test-none.ddd'
def test_matching_configs_any_scheme():
'''
Test matching exact palette and scheme. Given strict mode not set (allowing relaxation
to "none"), the order of matching should be
1. (none, scheme) :: none-light.aaa & none-none.aaa
2. (none, none) :: none-none.aaa
3. (palette, scheme) :: test-dark.bbb & test-light.ccc & test-none.aaa & test-none.ddd
4. (palette, none) :: test-none.aaa & test-none.ddd
Yielding "test-none.aaa", "test-none.ddd", "test-light.ccc", "test-dark.bbb"
'''
test_any = cm.get_matching_configs(
'test',
palette='test',
scheme='any',
)
assert len(test_any) == 4
assert test_any['aaa'].name == 'test-none.aaa'
assert test_any['bbb'].name == 'test-dark.bbb'
assert test_any['ccc'].name == 'test-light.ccc'
assert test_any['ddd'].name == 'test-none.ddd'
def test_matching_scripts():
'''
Test matching exact palette and scheme. Given strict mode not set (allowing relaxation
to "none"), the order of matching should be
1. (none, none) :: none-none.sh
2. (none, scheme) :: none-light.sh
3. (palette, none) :: test-none.sh
4. (palette, scheme) :: (nothing)
Yielding (ordered by dec specificity) "test-none.sh", "none-light.sh", "none-none.sh".
'''
test_any = cm.get_matching_scripts(
'test',
palette='test',
scheme='any',
)
assert len(test_any) == 3
assert test_any == ['test-none.sh', 'none-light.sh', 'none-none.sh']
any_light = cm.get_matching_scripts(
'test',
palette='any',
scheme='light',
)
assert len(any_light) == 3
assert any_light == ['test-none.sh', 'none-light.sh', 'none-none.sh']
any_dark = cm.get_matching_scripts(
'test',
palette='any',
scheme='dark',
)
assert len(any_dark) == 2
assert any_dark == ['test-none.sh', 'none-none.sh']

View File

@@ -1,5 +1,11 @@
def test_imports():
from symconf 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 reader
from symconf import theme
from symconf import util

120
tests/test_matching.py Normal file
View File

@@ -0,0 +1,120 @@
from pathlib import Path
from symconf import ConfigManager
config_dir = Path(
__file__, '..', 'test-config-dir/'
).resolve()
cm = ConfigManager(config_dir)
def test_matching_configs_exact():
'''
Test matching exact style and scheme. Given strict mode not set (allowing relaxation
to "none"), the order of matching should be
1. (none, none) :: none-none.aaa
2. (none, scheme) :: none-light.aaa
3. (style, none) :: test-none.aaa
4. (style, scheme) :: test-light.ccc
Yielding "test-light.aaa", "test-light.ccc" (unique only on config pathname).
'''
any_light = cm.get_matching_configs(
'test',
style='test',
scheme='light',
)
print(any_light)
assert len(any_light) == 2
assert any_light['aaa'].pathname == 'test-none.aaa'
assert any_light['ccc'].pathname == 'test-light.ccc'
def test_matching_configs_any_style():
'''
Test matching "any" style and exact scheme. Given strict mode not set (allowing
relaxation to "none"), the order of matching should be
1. (style, none) :: none-none.aaa, test-none.aaa
2. (none, none) :: none-none.aaa
3. (style, scheme) :: test-dark.bbb
4. (none, scheme) :: (nothing)
Yielding "none-none.aaa" (should always overwrite "test-none.aaa" due to "any"'s
preference for non-specific matches, i.e., "none"s), "test-none.ddd", "test-dark.bbb"
(unique only on config pathname).
'''
any_dark = cm.get_matching_configs(
'test',
style='any',
scheme='dark',
)
assert len(any_dark) == 2
assert any_dark['aaa'].pathname == 'none-none.aaa'
assert any_dark['bbb'].pathname == 'test-dark.bbb'
def test_matching_configs_any_scheme():
'''
Test matching exact style and "any" scheme. Given strict mode not set (allowing
relaxation to "none"), the order of matching should be
1. (none, scheme) :: none-light.aaa & none-none.aaa
2. (none, none) :: none-none.aaa
3. (style, scheme) :: test-dark.bbb & test-light.ccc & test-none.aaa
4. (style, none) :: test-none.aaa
Yielding "test-none.aaa", "test-light.ccc", "test-dark.bbb"
'''
test_any = cm.get_matching_configs(
'test',
style='test',
scheme='any',
)
assert len(test_any) == 3
assert test_any['aaa'].pathname == 'test-none.aaa'
assert test_any['bbb'].pathname == 'test-dark.bbb'
assert test_any['ccc'].pathname == 'test-light.ccc'
def test_matching_scripts():
'''
Test matching exact style and scheme. Given strict mode not set (allowing relaxation
to "none"), the order of matching should be
1. (none, none) :: none-none.sh
2. (none, scheme) :: none-light.sh
3. (style, none) :: test-none.sh
4. (style, scheme) :: (nothing)
Yielding (ordered by dec specificity) "test-none.sh" as primary match, then relaxation
match "none-none.sh".
'''
test_any = cm.get_matching_scripts(
'test',
style='test',
scheme='any',
)
assert len(test_any) == 2
assert list(map(lambda p:p.pathname, test_any)) == ['test-none.sh', 'none-none.sh']
any_light = cm.get_matching_scripts(
'test',
style='any',
scheme='light',
)
assert len(any_light) == 2
assert list(map(lambda p:p.pathname, any_light)) == ['none-light.sh', 'none-none.sh']
any_dark = cm.get_matching_scripts(
'test',
style='any',
scheme='dark',
)
assert len(any_dark) == 2
assert list(map(lambda p:p.pathname, any_dark)) == ['test-none.sh', 'none-none.sh']

31
tests/test_template.py Normal file
View File

@@ -0,0 +1,31 @@
from pathlib import Path
from symconf import Template, TOMLTemplate
def test_template_fill():
# test simple replacment
assert Template('f{{a}} - f{{b}}').fill({
'a': 1,
'b': 2,
}) == '1 - 2'
# test nested brackets (using default pattern)
assert Template('{{ f{{a}} - f{{b}} }}').fill({
'a': 1,
'b': 2,
}) == '{{ 1 - 2 }}'
# test tight nested brackets (requires greedy quantifier)
assert Template('{{f{{a}} - f{{b}}}}').fill({
'a': 1,
'b': 2,
}) == '{{1 - 2}}'
def test_toml_template_fill():
test_group_dir = Path(
__file__, '..', 'test-config-dir/groups/test/'
).resolve()
stacked_dict = TOMLTemplate.stack_toml(test_group_dir.iterdir())
assert stacked_dict == {'base':'aaa','concrete':'zzz'}