Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c0d94b2de7 | |||
| 4ab6c4f100 | |||
| e2f1fd30b6 | |||
| bf311d57a5 | |||
| cb1dd52833 | |||
| 8a78da1a28 | |||
| ec5893581e | |||
| 8afdadc263 | |||
| 5bb280b1da | |||
| 533c533034 | |||
| 3120638ef3 | |||
| 2f78fa0527 | |||
| b72de8e28f |
134
README.md
134
README.md
@@ -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
|
||||

|
||||
|
||||
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
|
||||
|
||||
8
TODO.md
8
TODO.md
@@ -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
|
||||
|
||||
20
docs/Makefile
Normal file
20
docs/Makefile
Normal file
@@ -0,0 +1,20 @@
|
||||
# Minimal makefile for Sphinx documentation
|
||||
#
|
||||
|
||||
# You can set these variables from the command line, and also
|
||||
# from the environment for the first two.
|
||||
SPHINXOPTS ?=
|
||||
SPHINXBUILD ?= sphinx-build
|
||||
SOURCEDIR = .
|
||||
BUILDDIR = _build
|
||||
|
||||
# Put it first so that "make" without argument is like "make help".
|
||||
help:
|
||||
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
||||
|
||||
.PHONY: help Makefile
|
||||
|
||||
# Catch-all target: route all unknown targets to Sphinx using the new
|
||||
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
|
||||
%: Makefile
|
||||
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
||||
BIN
docs/_static/example.gif
vendored
Normal file
BIN
docs/_static/example.gif
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 MiB |
@@ -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.
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -28,6 +28,9 @@ dependencies = [
|
||||
"colorama",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
symconf = "symconf.__main__:main"
|
||||
|
||||
[project.optional-dependencies]
|
||||
tests = ["pytest"]
|
||||
docs = [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,15 +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()
|
||||
print(args)
|
||||
|
||||
if 'func' in args:
|
||||
args.func(args)
|
||||
else:
|
||||
parser.print_help()
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
@@ -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,87 +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
|
||||
))
|
||||
|
||||
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))
|
||||
|
||||
# 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)
|
||||
rel_theme_matches = ' < '.join([
|
||||
str(fp.path.relative_to(self.group_dir))
|
||||
for fp in theme_matches
|
||||
])
|
||||
for pathname in generated_config:
|
||||
print(
|
||||
Fore.BLUE + Style.DIM + f'-> Captured script output "{output.decode().strip()}"' + Style.RESET_ALL
|
||||
color_text("│", Fore.BLUE),
|
||||
color_text(
|
||||
f' > generating config "{pathname}" from [{rel_theme_matches}]',
|
||||
Style.DIM
|
||||
)
|
||||
)
|
||||
|
||||
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'> {app_name} :: {from_p} -> {to_p}')
|
||||
self._symlink_paths(to_symlink)
|
||||
self.runner.run_many(script_list)
|
||||
|
||||
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'> {app_name} :: {from_p} -> {to_p}')
|
||||
|
||||
def update_apps(
|
||||
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
|
||||
@@ -496,10 +699,68 @@ class ConfigManager:
|
||||
print(f'None of the apps "{apps}" are registered, exiting')
|
||||
return
|
||||
|
||||
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
251
symconf/matching.py
Normal 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
94
symconf/reader.py
Normal 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
61
symconf/runner.py
Normal 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
70
symconf/template.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
[app.test]
|
||||
config_dir = 'sym_tgt/test'
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
gsettings set org.gnome.desktop.interface color-scheme 'prefer-dark'
|
||||
@@ -1 +0,0 @@
|
||||
gsettings set org.gnome.desktop.interface color-scheme 'prefer-light'
|
||||
@@ -1 +1 @@
|
||||
echo "> none-light ran"
|
||||
echo "none-light ran"
|
||||
|
||||
@@ -1 +1 @@
|
||||
echo "> none-none ran"
|
||||
echo "none-none ran"
|
||||
|
||||
1
tests/test-config-dir/groups/test/none.toml
Normal file
1
tests/test-config-dir/groups/test/none.toml
Normal file
@@ -0,0 +1 @@
|
||||
base = "aaa"
|
||||
1
tests/test-config-dir/groups/test/test.toml
Normal file
1
tests/test-config-dir/groups/test/test.toml
Normal file
@@ -0,0 +1 @@
|
||||
concrete = "zzz"
|
||||
@@ -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']
|
||||
@@ -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
120
tests/test_matching.py
Normal 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
31
tests/test_template.py
Normal 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'}
|
||||
Reference in New Issue
Block a user