add template generation, generalize search heuristics

This commit is contained in:
Sam G. 2024-07-07 04:21:51 -07:00
parent 2f78fa0527
commit 3120638ef3
5 changed files with 349 additions and 49 deletions

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

@ -1,4 +1,5 @@
import os import os
import re
import json import json
import inspect import inspect
import tomllib import tomllib
@ -9,6 +10,7 @@ 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
class ConfigManager: class ConfigManager:
@ -81,34 +83,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]:
''' '''
@ -220,6 +200,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,7 +213,7 @@ 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] palette_part, scheme_part = '-'.join(theme_split[:-1]), theme_split[-1]
@ -238,10 +221,154 @@ class ConfigManager:
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.
'''
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('-')
palette_part, scheme_part = '-'.join(theme_split[:-1]), theme_split[-1]
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
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())
# 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 +437,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 +450,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.exists():
for template_file in template_dir.iterdir():
template_map[template_file.name] = template_file
return template_map, template_dict, max_idx
def get_matching_scripts( def get_matching_scripts(
self, self,
app_name, app_name,
@ -372,16 +523,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 +557,42 @@ 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, 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)
file_map = {}
for tail, full_path in template_map.items():
if tail in config_map and config_map[tail][2] >= tidx:
file_map[tail] = config_map[tail]
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
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,8 +608,6 @@ class ConfigManager:
full_path, # to internal config location full_path, # to internal config location
)) ))
print('├─ ' + Fore.YELLOW + f'{app_name} :: matched {len(to_symlink)} config files')
links_succ = [] links_succ = []
links_fail = [] links_fail = []
for from_path, to_path in to_symlink: for from_path, to_path in to_symlink:
@ -457,6 +639,17 @@ 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
script_list = self.get_matching_scripts(
app_name,
scheme=scheme,
palette=palette,
)
print(
'├─ ' + Fore.YELLOW + f'{app_name} :: matched {len(to_symlink)} config files and {len(script_list)} scripts'
)
# link report # link report
for from_p, to_p in links_succ: for from_p, to_p in links_succ:
from_p = from_p from_p = from_p
@ -468,12 +661,6 @@ class ConfigManager:
to_p = to_p.relative_to(self.config_dir) to_p = to_p.relative_to(self.config_dir)
print(Fore.RED + f'│ > failed to link {from_p} -> {to_p}') print(Fore.RED + f'│ > failed to link {from_p} -> {to_p}')
# run matching scripts for app-specific reload
script_list = self.get_matching_scripts(
app_name,
scheme=scheme,
palette=palette,
)
for script in script_list: for script in script_list:
print(Fore.BLUE + f'│ > running script "{script.relative_to(self.config_dir)}"') print(Fore.BLUE + f'│ > running script "{script.relative_to(self.config_dir)}"')
@ -491,6 +678,8 @@ class ConfigManager:
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
@ -514,4 +703,6 @@ class ConfigManager:
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']