Compare commits

..

9 Commits

28 changed files with 1441 additions and 696 deletions

147
README.md
View File

@ -1,11 +1,142 @@
# 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 (along with a statusline theme) is generated from the
chosen palette, and running instances of `neovim` are sent a message to re-source this
theme (via `nvim --remote-send`)
- **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.md).
# 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.md) 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 indicator, 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`).
- `symconf generate` is a subcommand that can be used for batch generation of config
files. It accepts the same arguments as `symconf config`, but rather than selecting the
best match to be used for the system setting, all matching templates are generated.
There is one additional required argument:
* `-o --output-dir`: the directory under which generated config files should be written.
App-specific subdirectories are created to house config files for each provided app.
- `symconf install`: runs install scripts for matching apps that specify one
* `-a --apps`: comma-separate list of registered apps, or `"*"` (default) to consider
all registered apps.
- `symconf update`: runs update scripts for matching apps that specify one
* `-a --apps`: comma-separate list of registered apps, or `"*"` (default) to consider
all registered apps.
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 +1,3 @@
- Add local app caching for `auto` logic
- Better script handling: 1) should be live, basically reconnecting output to native
terminal, 2) catch and print errors from STDERR

BIN
docs/_static/example.gif vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 MiB

View File

@ -3,22 +3,29 @@
{ref}`modindex`
{ref}`search`
## Top-level module overview
```{eval-rst}
.. autosummary::
:nosignatures:
:recursive:
# list modules here for quick links
symconf.config
symconf.template
symconf.matching
symconf.reader
symconf.runner
```
## Auto-reference contents
```{toctree}
:maxdepth: 3
:caption: Autoref
_autoref/symconf.rst
```
```{toctree}
:maxdepth: 3
:maxdepth: 2
:caption: Contents
reference/configuring
@ -28,4 +35,7 @@ reference/documentation/index
```
```{include} ../README.md
:relative-docs: docs/
:relative-images:
```

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

@ -1,7 +1,16 @@
from symconf.config import ConfigManager
from symconf.runner import Runner
from symconf.reader import DictReader
from symconf.config import ConfigManager
from symconf.matching import Matcher, FilePart
from symconf.template import Template, FileTemplate, TOMLTemplate
from symconf import config
from symconf import matching
from symconf import reader
from symconf import theme
from symconf import template
from symconf import util
from importlib.metadata import version
__version__ = version('symconf')

View File

@ -1,6 +1,7 @@
import argparse
from importlib.metadata import version
from symconf import util
from symconf import util, __version__
from symconf.config import ConfigManager
@ -35,7 +36,7 @@ def add_update_subparser(subparsers):
parser.add_argument(
'-a', '--apps',
required = False,
default = "*",
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'
@ -43,12 +44,13 @@ def add_update_subparser(subparsers):
parser.set_defaults(func=update_apps)
def add_config_subparser(subparsers):
def config_apps(args):
def configure_apps(args):
cm = ConfigManager(args.config_dir)
cm.config_apps(
cm.configure_apps(
apps=args.apps,
scheme=args.scheme,
palette=args.palette,
scheme=args.mode,
style=args.style,
**args.template_vars
)
parser = subparsers.add_parser(
@ -56,16 +58,17 @@ def add_config_subparser(subparsers):
description='Set config files for registered applications.'
)
parser.add_argument(
'-p', '--palette',
'-s', '--style',
required = False,
default = "any",
help = 'Palette name, must match a folder in themes/'
default = 'any',
help = 'Style indicator (often a color palette) capturing thematic details in '
'a config file'
)
parser.add_argument(
'-s', '--scheme',
'-m', '--mode',
required = False,
default = "any",
help = 'Preferred lightness scheme, either "light" or "dark".'
help = 'Preferred lightness mode/scheme, either "light," "dark," "any," or "none."'
)
parser.add_argument(
'-a', '--apps',
@ -79,10 +82,63 @@ def add_config_subparser(subparsers):
'-T', '--template-vars',
required = False,
nargs='+',
default = {},
action=util.KVPair,
help='Groups to use when populating templates, in the form group=value'
)
parser.set_defaults(func=config_apps)
parser.set_defaults(func=configure_apps)
def add_generate_subparser(subparsers):
def generate_apps(args):
cm = ConfigManager(args.config_dir)
cm.generate_app_templates(
gen_dir=args.output_dir,
apps=args.apps,
scheme=args.mode,
style=args.style,
**args.template_vars
)
parser = subparsers.add_parser(
'generate',
description='Generate all template config files for specified apps'
)
parser.add_argument(
'-o', '--output-dir',
required = True,
type = util.absolute_path,
help = 'Path to write generated template files'
)
parser.add_argument(
'-s', '--style',
required = False,
default = 'any',
help = 'Style indicator (often a color palette) capturing thematic details in '
'a config file'
)
parser.add_argument(
'-m', '--mode',
required = False,
default = "any",
help = 'Preferred lightness mode/scheme, either "light," "dark," "any," or "none."'
)
parser.add_argument(
'-a', '--apps',
required = False,
default = "*",
type = lambda s: s.split(',') if s != '*' else s,
help = 'Application target for theme. App must be present in the registry. ' \
+ 'Use "*" to apply to all registered apps'
)
parser.add_argument(
'-T', '--template-vars',
required = False,
nargs = '+',
default = {},
action = util.KVPair,
help = 'Groups to use when populating templates, in the form group=value'
)
parser.set_defaults(func=generate_apps)
# central argparse entry point
@ -96,12 +152,19 @@ parser.add_argument(
type = util.absolute_path,
help = 'Path to config directory'
)
parser.add_argument(
'-v', '--version',
action='version',
version=__version__,
help = 'Print symconf version'
)
# add subparsers
subparsers = parser.add_subparsers(title='subcommand actions')
add_config_subparser(subparsers)
add_generate_subparser(subparsers)
add_install_subparser(subparsers)
add_update_subparser(subparsers)
add_config_subparser(subparsers)
def main():

File diff suppressed because it is too large Load Diff

253
symconf/matching.py Normal file
View File

@ -0,0 +1,253 @@
'''
Generic combinatorial name-matching subsystem
Config files are expected to have names matching the following spec:
.. code-block:: sh
<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
.. code-block:: sh
soft-gruvbox-dark.kitty.conf
gets mapped to
.. code-block:: 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
.. code-block:: 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
.. code-block:: python
[
('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())

View File

@ -1,3 +1,6 @@
'''
Simplified management for nested dictionaries
'''
import copy
import pprint
import tomllib

64
symconf/runner.py Normal file
View File

@ -0,0 +1,64 @@
'''
Handle job/script execution
'''
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

109
symconf/template.py Normal file
View File

@ -0,0 +1,109 @@
'''
Support for basic config templates
'''
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,
key_pattern : str = r'f{{(\S+?)}}',
exe_pattern : str = r'x{{((?:(?!x{{).)*)}}',
):
self.template_str = template_str
self.key_pattern = key_pattern
self.exe_pattern = exe_pattern
def fill(
self,
template_dict : dict,
) -> str:
dr = DictReader.from_dict(template_dict)
exe_filled = re.sub(
self.exe_pattern,
lambda m: self._exe_fill(m, dr),
self.template_str
)
key_filled = re.sub(
self.key_pattern,
lambda m: self._key_fill(m, dr),
exe_filled
)
return key_filled
def _key_fill(
self,
match,
dict_reader: DictReader,
) -> str:
key = match.group(1)
return str(dict_reader.get(key))
def _exe_fill(
self,
match,
dict_reader: DictReader,
) -> str:
key_fill = re.sub(
self.key_pattern,
lambda m: f'"{self._key_fill(m, dict_reader)}"',
match.group(1)
)
return str(eval(key_fill))
class FileTemplate(Template):
def __init__(
self,
path: Path,
key_pattern: str = r'f{{(\S+?)}}',
exe_pattern : str = r'x{{((?:(?!x{{).)*)}}',
):
super().__init__(
path.open('r').read(),
key_pattern=key_pattern,
exe_pattern=exe_pattern,
)
class TOMLTemplate(FileTemplate):
def __init__(
self,
toml_path: Path,
key_pattern: str = r'f{{(\S+?)}}',
exe_pattern : str = r'x{{((?:(?!x{{).)*)}}',
):
super().__init__(
toml_path,
key_pattern=key_pattern,
exe_pattern=exe_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,74 +0,0 @@
import argparse
import inspect
import json
import tomllib as toml
from pathlib import Path
# separation sequences to use base on app
app_sep_map = {
'kitty': ' ',
}
def generate_theme_files():
basepath = get_running_path()
# set arg conditional variables
palette_path = Path(basepath, 'themes', args.palette)
colors_path = Path(palette_path, 'colors.json')
theme_app = args.app
template_path = None
output_path = None
if args.template is None:
template_path = Path(palette_path, 'apps', theme_app, 'templates')
else:
template_path = Path(args.template).resolve()
if args.output is None:
output_path = Path(palette_path, 'apps', theme_app, 'generated')
else:
output_path = Path(args.output).resolve()
# check paths
if not colors_path.exists():
print(f'Resolved colors path [{colors_path}] doesn\'t exist, exiting')
return
if not template_path.exists():
print(f'Template path [{template_path}] doesn\'t exist, exiting')
return
if not output_path.exists() or not output_path.is_dir():
print(f'Output path [{output_path}] doesn\'t exist or not a directory, exiting')
return
print(f'Using palette colors [{colors_path}]')
print(f'-> with templates in [{template_path}]')
print(f'-> to output path [{output_path}]\n')
# load external files (JSON, TOML)
colors_json = json.load(colors_path.open())
# get all matching TOML files
template_list = [template_path]
if template_path.is_dir():
template_list = template_path.rglob('*.toml')
for template_path in template_list:
template_toml = toml.load(template_path.open('rb'))
# lookup app-specific config separator
config_sep = app_sep_map.get(theme_app, ' ')
output_lines = []
for config_key, color_key in template_toml.items():
color_value = colors_json
for _key in color_key.split('.'):
color_value = color_value.get(_key, {})
output_lines.append(f'{config_key}{config_sep}{color_value}')
output_file = Path(output_path, template_path.stem).with_suffix('.conf')
output_file.write_text('\n'.join(output_lines))
print(f'[{len(output_lines)}] lines written to [{output_file}] for app [{theme_app}]')

View File

@ -1,13 +1,61 @@
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'''

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,132 +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" as primary match, then relaxation
match "none-none.sh".
'''
test_any = cm.get_matching_scripts(
'test',
palette='test',
scheme='any',
)
assert len(test_any) == 2
assert list(map(lambda p:p.name, test_any)) == ['test-none.sh', 'none-none.sh']
any_light = cm.get_matching_scripts(
'test',
palette='any',
scheme='light',
)
assert len(any_light) == 2
assert list(map(lambda p:p.name, any_light)) == ['none-light.sh', 'none-none.sh']
any_dark = cm.get_matching_scripts(
'test',
palette='any',
scheme='dark',
)
assert len(any_dark) == 2
assert list(map(lambda p:p.name, any_dark)) == ['test-none.sh', 'none-none.sh']

View File

@ -1,5 +1,10 @@
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 theme
from symconf import reader
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'}