4 Commits
0.3.1 ... 0.4.2

Author SHA1 Message Date
533c533034 add support for palette variants, minor bug fixes 2024-07-07 16:30:07 -07:00
3120638ef3 add template generation, generalize search heuristics 2024-07-07 04:21:51 -07:00
2f78fa0527 clean up for refined release 2024-07-05 04:15:06 -07:00
b72de8e28f improve output formatting 2024-07-05 04:11:54 -07:00
7 changed files with 424 additions and 67 deletions

20
docs/Makefile Normal file
View 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)

View File

@@ -1,5 +1,7 @@
from symconf.config import ConfigManager from symconf.config import ConfigManager
from symconf.reader import DictReader
from symconf import config from symconf import config
from symconf import reader
from symconf import theme from symconf import theme
from symconf import util from symconf import util

View File

@@ -94,7 +94,6 @@ add_set_subparser(subparsers)
if __name__ == '__main__': if __name__ == '__main__':
args = parser.parse_args() args = parser.parse_args()
print(args)
if 'func' in args: if 'func' in args:
args.func(args) args.func(args)

View File

@@ -1,4 +1,5 @@
import os import os
import re
import json import json
import inspect import inspect
import tomllib import tomllib
@@ -9,7 +10,10 @@ from pathlib import Path
from colorama import Fore, Back, Style from colorama import Fore, Back, Style
from symconf import util from symconf import util
from symconf.reader import DictReader
def y(t):
return Style.RESET_ALL + Fore.YELLOW + t + Style.RESET_ALL
class ConfigManager: class ConfigManager:
def __init__( def __init__(
@@ -81,34 +85,12 @@ class ConfigManager:
self.app_registry = app_registry.get('app', {}) self.app_registry = app_registry.get('app', {})
def _resolve_scheme(self, scheme): def _resolve_group(self, group, value='auto'):
# if scheme == 'auto': if value == 'auto':
# os_cmd_groups = { # look group up in app cache and set to current value
# 'Linux': (
# "gsettings get org.gnome.desktop.interface color-scheme",
# lambda r: r.split('-')[1][:-1],
# ),
# 'Darwin': (),
# }
# 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':
return 'any' 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]: def app_config_map(self, app_name) -> dict[str, Path]:
''' '''
@@ -134,14 +116,10 @@ class ConfigManager:
''' '''
# first look in "generated", then overwrite with "user" # first look in "generated", then overwrite with "user"
file_map = {} file_map = {}
app_dir = Path(self.apps_dir, app_name) user_app_dir = Path(self.apps_dir, app_name, 'user')
for subdir in ['generated', 'user']:
subdir_path = Path(app_dir, subdir)
if not subdir_path.is_dir(): if user_app_dir.is_dir():
continue for conf_file in user_app_dir.iterdir():
for conf_file in subdir_path.iterdir():
file_map[conf_file.name] = conf_file file_map[conf_file.name] = conf_file
return file_map return file_map
@@ -220,6 +198,9 @@ class ConfigManager:
prefix_order=None, prefix_order=None,
strict=False, strict=False,
): ):
'''
Find and return matches along the "match trajectory."
'''
file_parts = self._get_file_parts(pathnames) file_parts = self._get_file_parts(pathnames)
if prefix_order is None: if prefix_order is None:
@@ -230,18 +211,172 @@ class ConfigManager:
) )
ordered_matches = [] ordered_matches = []
for palette_prefix, scheme_prefix in prefix_order: for i, (palette_prefix, scheme_prefix) in enumerate(prefix_order):
for theme_part, conf_part, pathname in file_parts: for theme_part, conf_part, pathname in file_parts:
theme_split = theme_part.split('-') theme_split = theme_part.split('-')
palette_part, scheme_part = '-'.join(theme_split[:-1]), theme_split[-1] scheme_part = theme_split[-1]
palette_part = '-'.join(theme_split[:-1])
palette_match = palette_prefix == palette_part or palette_prefix == 'any' palette_match = palette_prefix == palette_part or palette_prefix == 'any'
scheme_match = scheme_prefix == scheme_part or scheme_prefix == 'any' scheme_match = scheme_prefix == scheme_part or scheme_prefix == 'any'
if palette_match and scheme_match: if palette_match and scheme_match:
ordered_matches.append((conf_part, theme_part, pathname)) ordered_matches.append((conf_part, theme_part, pathname, i+1))
return ordered_matches return ordered_matches
def _get_relaxed_set(
self,
match_list
):
'''
Mostly to filter "any" matches, latching onto a particular result and getting
only its relaxed variants.
Note that palette-scheme files can be named ``<variant>-<palette>-<scheme>``
'''
if not match_list:
return []
match = match_list[-1]
theme_split = match[1].split('-')
palette_tgt, scheme_tgt = '-'.join(theme_split[:-1]), theme_split[-1]
relaxed_map = {}
for conf_part, theme_part, pathname, idx in match_list:
#theme_split = theme_part.split('-')[::-1]
#scheme_part = theme_split[0]
#palette_part = theme_split[1]
theme_split = theme_part.split('-')
scheme_part = theme_split[-1]
palette_part = '-'.join(theme_split[:-1])
#pvar_part = '-'.join(theme_split[2:])
palette_match = palette_part == palette_tgt or palette_part == 'none'
scheme_match = scheme_part == scheme_tgt or scheme_part == 'none'
if palette_match and scheme_match:
relaxed_map[pathname] = (conf_part, theme_part, pathname, idx)
return list(relaxed_map.values())
def _stack_toml(
self,
path_list
):
stacked_dict = {}
for toml_path in path_list:
updated_map = tomllib.load(toml_path.open('rb'))
stacked_dict = util.deep_update(stacked_dict, updated_map)
return stacked_dict
def template_fill(
self,
template_str : str,
template_dict : dict,
pattern : str = r'f{{(\S+)}}',
):
dr = DictReader.from_dict(template_dict)
return re.sub(
pattern,
lambda m:str(dr.get(m.group(1))),
template_str
)
def get_matching_group_dict(
self,
scheme='auto',
palette='auto',
**kw_groups,
) -> dict:
'''
Note that "strictness" doesn't really apply in this setting. In the config
matching setting, setting strict means there's no relaxation to "none," but here,
any "none" group files just help fill any gaps (but are otherwise totally
overwritten, even if matched, by more precise matches). You can match ``nones``
directly if you want as well. ``get_matching_scripts()`` is similar in this sense.
'''
scheme = self._resolve_group('scheme', scheme)
palette = self._resolve_group('palette', palette)
groups = {
k : self._resolve_group(k, v)
for k, v in kw_groups.items()
}
# palette lookup will behave like other groups
groups['palette'] = palette.split('-')[-1]
group_dir = Path(self.config_dir, 'groups')
if not group_dir.exists():
return {}
# handle non-palette-scheme groups
group_matches = {}
for fkey, fval in groups.items():
key_group_dir = Path(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()}
if fval == 'any':
prefix_order = [fval, 'none']
else:
prefix_order = ['none', fval]
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
match_dict = {}
tgt = matches[-1] # select best based on order, make new target
for stem in matches:
if stem == tgt or stem == 'none':
match_dict[stem] = stem_map[stem]
group_matches[fkey] = list(match_dict.values())
print(group_matches)
# first handle scheme maps; matching palette files should already be found in the
# regular group matching process
palette_dict = self._stack_toml(group_matches.get('palette', []))
# then palette-scheme groups (require 2-combo logic)
scheme_group_dir = Path(group_dir, 'scheme')
scheme_pathnames = [path.name for path in scheme_group_dir.iterdir()]
ordered_matches = self.match_pathnames(
scheme_pathnames,
scheme,
palette,
)
relaxed_matches = self._get_relaxed_set(ordered_matches)
scheme_dict = {}
for conf_part, theme_part, toml_path, _ in relaxed_matches:
toml_str = Path(scheme_group_dir, toml_path).open('r').read()
filled_toml = self.template_fill(toml_str, palette_dict)
toml_dict = tomllib.loads(filled_toml)
scheme_dict = util.deep_update(scheme_dict, toml_dict)
template_dict = {
group : self._stack_toml(ordered_matches)
for group, ordered_matches in group_matches.items()
}
template_dict['scheme'] = scheme_dict
return template_dict, relaxed_matches
def get_matching_configs( def get_matching_configs(
self, self,
app_name, app_name,
@@ -310,8 +445,8 @@ class ConfigManager:
''' '''
app_dir = Path(self.apps_dir, app_name) app_dir = Path(self.apps_dir, app_name)
scheme = self._resolve_scheme(scheme) scheme = self._resolve_group('scheme', scheme)
palette = self._resolve_palette(palette) palette = self._resolve_group('palette', palette)
app_config_map = self.app_config_map(app_name) app_config_map = self.app_config_map(app_name)
@@ -323,11 +458,35 @@ class ConfigManager:
) )
matching_file_map = {} matching_file_map = {}
for conf_part, theme_part, pathname in ordered_matches: for conf_part, theme_part, pathname, idx in ordered_matches:
matching_file_map[conf_part] = app_config_map[pathname] matching_file_map[conf_part] = (app_config_map[pathname], idx)
return matching_file_map return matching_file_map
def get_matching_templates(
self,
app_name,
scheme='auto',
palette='auto',
**kw_groups,
) -> dict:
template_dict, relaxed_matches = self.get_matching_group_dict(
scheme=scheme,
palette=palette,
**kw_groups,
)
max_idx = 0
if relaxed_matches:
max_idx = max([m[3] for m in relaxed_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, relaxed_matches, max_idx
def get_matching_scripts( def get_matching_scripts(
self, self,
app_name, app_name,
@@ -372,16 +531,19 @@ class ConfigManager:
palette, palette,
prefix_order=prefix_order prefix_order=prefix_order
) )
relaxed_matches = self._get_relaxed_set(ordered_matches)
# flip list to execute by decreasing specificity # flip list to execute by decreasing specificity
return list(dict.fromkeys(map(lambda x:Path(call_dir, x[2]), ordered_matches)))[::-1] return list(map(lambda x:Path(call_dir, x[2]), relaxed_matches))[::-1]
def update_app_config( def update_app_config(
self, self,
app_name, app_name,
app_settings = None, app_settings = None,
strict = False,
scheme = 'any', scheme = 'any',
palette = 'any', palette = 'any',
**kw_groups,
): ):
''' '''
Perform full app config update process, applying symlinks and running scripts. Perform full app config update process, applying symlinks and running scripts.
@@ -403,12 +565,54 @@ class ConfigManager:
print(f'App "{app_name}" incorrectly configured, skipping') print(f'App "{app_name}" incorrectly configured, skipping')
return return
to_symlink: list[tuple[Path, Path]] = [] # merge templates and user-provided configs
file_map = self.get_matching_configs( template_map, template_dict, template_matches, tidx = self.get_matching_templates(
app_name, app_name,
scheme=scheme, scheme=scheme,
palette=palette, palette=palette,
**kw_groups
) )
# set file map to user configs if yields a strictly better match
config_map = self.get_matching_configs(
app_name,
scheme=scheme,
palette=palette,
strict=strict,
)
# tuples of 1) full paths and 2) whether to fill template
generated_path = Path(self.apps_dir, app_name, 'generated')
generated_path.mkdir(parents=True, exist_ok=True)
# flatten matches
generated_paths = [m[2] for m in template_matches]
generated_config = {}
file_map = {}
for tail, full_path in template_map.items():
# use config only if strictly better match
# the P-S match forms rules the match quality; if additional args from
# templates (e.g. "font") match available groups but there is still a better
# P-S match in "user/", it will beat out the template (b/c the "user" config
# is concrete). If they're on the same level, prefer the template match for
# flexibility (guarantees same P-S match and extra group customization).
if tail in config_map and config_map[tail][1] > tidx:
file_map[tail] = config_map[tail][0]
else:
template_str = full_path.open('r').read()
filled_template = self.template_fill(template_str, template_dict)
config_path = Path(generated_path, tail)
config_path.write_text(filled_template)
file_map[tail] = config_path
generated_config[tail] = generated_paths
for tail, (full_path, idx) in config_map.items():
if tail not in file_map:
file_map[tail] = full_path
to_symlink: list[tuple[Path, Path]] = []
if 'config_dir' in app_settings: if 'config_dir' in app_settings:
for config_tail, full_path in file_map.items(): for config_tail, full_path in file_map.items():
to_symlink.append(( to_symlink.append((
@@ -424,6 +628,22 @@ class ConfigManager:
full_path, # to internal config location full_path, # to internal config location
)) ))
# run matching scripts for app-specific reload
script_list = self.get_matching_scripts(
app_name,
scheme=scheme,
palette=palette,
)
print(
f'{y("├─")} ' + Fore.YELLOW + f'{app_name} :: matched ({len(to_symlink)}) config files and ({len(script_list)}) scripts'
)
for tail, gen_paths in generated_config.items():
print(
f'{y("")}' + Fore.GREEN + Style.DIM + \
f' > generating config "{tail}" from {gen_paths}' + Style.RESET_ALL
)
links_succ = [] links_succ = []
links_fail = [] links_fail = []
for from_path, to_path in to_symlink: for from_path, to_path in to_symlink:
@@ -455,35 +675,37 @@ class ConfigManager:
from_path.symlink_to(to_path) from_path.symlink_to(to_path)
links_succ.append((from_path, to_path)) links_succ.append((from_path, to_path))
# run matching scripts for app-specific reload # link report
script_list = self.get_matching_scripts(
app_name,
scheme=scheme,
palette=palette,
)
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)
print(
Fore.BLUE + Style.DIM + f'-> Captured script output "{output.decode().strip()}"' + Style.RESET_ALL
)
for from_p, to_p in links_succ: for from_p, to_p in links_succ:
from_p = from_p from_p = from_p
to_p = to_p.relative_to(self.config_dir) to_p = to_p.relative_to(self.config_dir)
print(Fore.GREEN + f'> {app_name} :: {from_p} -> {to_p}') print(f'{y("")}' + Fore.GREEN + f' > linked {from_p} -> {to_p}')
for from_p, to_p in links_fail: for from_p, to_p in links_fail:
from_p = from_p from_p = from_p
to_p = to_p.relative_to(self.config_dir) to_p = to_p.relative_to(self.config_dir)
print(Fore.RED + f'> {app_name} :: {from_p} -> {to_p}') print(f'{y("")}' + Fore.RED + f' > failed to link {from_p} -> {to_p}')
for script in script_list:
print(f'{y("")}' + Fore.BLUE + f' > running script "{script.relative_to(self.config_dir)}"')
output = subprocess.check_output(str(script), shell=True)
if output:
fmt_output = output.decode().strip().replace('\n',f'\n{y("")} ')
print(
f'{y("")}' + \
Fore.BLUE + Style.DIM + \
f' > captured script output "{fmt_output}"' \
+ Style.RESET_ALL
)
def update_apps( def update_apps(
self, self,
apps: str | list[str] = '*', apps: str | list[str] = '*',
scheme = 'any', scheme = 'any',
palette = 'any', palette = 'any',
strict=False,
**kw_groups,
): ):
if apps == '*': if apps == '*':
# get all registered apps # get all registered apps
@@ -496,10 +718,17 @@ class ConfigManager:
print(f'None of the apps "{apps}" are registered, exiting') print(f'None of the apps "{apps}" are registered, exiting')
return return
print('> symconf parameters: ')
print(' > registered apps :: ' + Fore.YELLOW + f'{app_list}' + Style.RESET_ALL)
print(' > palette :: ' + Fore.YELLOW + f'{palette}' + Style.RESET_ALL)
print(' > scheme :: ' + Fore.YELLOW + f'{scheme}\n' + Style.RESET_ALL)
for app_name in app_list: for app_name in app_list:
self.update_app_config( self.update_app_config(
app_name, app_name,
app_settings=self.app_registry[app_name], app_settings=self.app_registry[app_name],
scheme=scheme, scheme=scheme,
palette=palette, palette=palette,
strict=False,
**kw_groups,
) )

94
symconf/reader.py Normal file
View File

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

View File

@@ -7,3 +7,15 @@ def absolute_path(path: str | Path) -> Path:
def xdg_config_path(): def xdg_config_path():
return Path(BaseDirectory.save_config_path('symconf')) return Path(BaseDirectory.save_config_path('symconf'))
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

View File

@@ -101,7 +101,8 @@ def test_matching_scripts():
3. (palette, none) :: test-none.sh 3. (palette, none) :: test-none.sh
4. (palette, scheme) :: (nothing) 4. (palette, scheme) :: (nothing)
Yielding (ordered by dec specificity) "test-none.sh", "none-light.sh", "none-none.sh". Yielding (ordered by dec specificity) "test-none.sh" as primary match, then relaxation
match "none-none.sh".
''' '''
test_any = cm.get_matching_scripts( test_any = cm.get_matching_scripts(
'test', 'test',
@@ -109,8 +110,8 @@ def test_matching_scripts():
scheme='any', scheme='any',
) )
assert len(test_any) == 3 assert len(test_any) == 2
assert test_any == ['test-none.sh', 'none-light.sh', 'none-none.sh'] assert list(map(lambda p:p.name, test_any)) == ['test-none.sh', 'none-none.sh']
any_light = cm.get_matching_scripts( any_light = cm.get_matching_scripts(
'test', 'test',
@@ -118,8 +119,8 @@ def test_matching_scripts():
scheme='light', scheme='light',
) )
assert len(any_light) == 3 assert len(any_light) == 2
assert any_light == ['test-none.sh', 'none-light.sh', 'none-none.sh'] assert list(map(lambda p:p.name, any_light)) == ['none-light.sh', 'none-none.sh']
any_dark = cm.get_matching_scripts( any_dark = cm.get_matching_scripts(
'test', 'test',
@@ -128,4 +129,4 @@ def test_matching_scripts():
) )
assert len(any_dark) == 2 assert len(any_dark) == 2
assert any_dark == ['test-none.sh', 'none-none.sh'] assert list(map(lambda p:p.name, any_dark)) == ['test-none.sh', 'none-none.sh']