371 lines
13 KiB
Python
371 lines
13 KiB
Python
|
import os
|
||
|
import json
|
||
|
import inspect
|
||
|
import tomllib
|
||
|
import argparse
|
||
|
import subprocess
|
||
|
from pathlib import Path
|
||
|
|
||
|
from colorama import Fore, Back, Style
|
||
|
|
||
|
from autoconf import util
|
||
|
|
||
|
|
||
|
class ConfigManager:
|
||
|
def __init__(
|
||
|
self,
|
||
|
config_dir=None,
|
||
|
disable_registry=False,
|
||
|
):
|
||
|
'''
|
||
|
Configuration manager class
|
||
|
|
||
|
Parameters:
|
||
|
config_dir: config parent directory housing expected files (registry,
|
||
|
app-specific conf files, etc). Defaults to
|
||
|
``"$XDG_CONFIG_HOME/autoconf/"``.
|
||
|
disable_registry: disable checks for a registry file in the ``config_dir``.
|
||
|
Should really only be set when using this programmatically
|
||
|
and manually supplying app settings.
|
||
|
'''
|
||
|
if config_dir == None:
|
||
|
config_dir = util.xdg_config_path()
|
||
|
|
||
|
self.config_dir = util.absolute_path(config_dir)
|
||
|
self.apps_dir = Path(self.config_dir, 'apps')
|
||
|
|
||
|
self.app_registry = {}
|
||
|
|
||
|
self._check_paths()
|
||
|
|
||
|
if not disable_registry:
|
||
|
self._check_registry()
|
||
|
|
||
|
def _check_paths(self):
|
||
|
'''
|
||
|
Check necessary paths for existence.
|
||
|
|
||
|
Regardless of programmatic use or ``disable_registry``, we need to 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).
|
||
|
'''
|
||
|
# throw error if config dir doesn't exist
|
||
|
if not self.config_dir.exists():
|
||
|
raise ValueError(
|
||
|
f'Config directory "{self.config_dir}" doesn\'t exist.'
|
||
|
)
|
||
|
|
||
|
# throw error if apps dir doesn't exist or is empty
|
||
|
if not self.apps_dir.exists() or not list(self.apps_dir.iterdir()):
|
||
|
raise ValueError(
|
||
|
f'Config directory "{self.config_dir}" must have an "apps/" subdirectory.'
|
||
|
)
|
||
|
|
||
|
def _check_registry(self):
|
||
|
registry_path = Path(self.config_dir, 'app_registry.toml')
|
||
|
|
||
|
self.app_registry = {}
|
||
|
if not registry_path.exists():
|
||
|
print(
|
||
|
Fore.YELLOW \
|
||
|
+ f'No registry file found at expected location "{registry_path}"'
|
||
|
)
|
||
|
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).'
|
||
|
)
|
||
|
|
||
|
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': (),
|
||
|
# }
|
||
|
|
||
|
# 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 scheme
|
||
|
|
||
|
def resolve_palette(self, palette):
|
||
|
if palette == 'auto':
|
||
|
return 'any'
|
||
|
|
||
|
return palette
|
||
|
|
||
|
def app_config_map(self, app_name):
|
||
|
'''
|
||
|
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/autoconf/apps/user/palette1-light.conf.ini
|
||
|
palette2-dark.app.conf -> ~/.config/autoconf/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_matching_configs(
|
||
|
self,
|
||
|
app_name,
|
||
|
scheme='auto',
|
||
|
palette='auto',
|
||
|
) -> dict[str, str]:
|
||
|
'''
|
||
|
Get app config files that match the provided scheme and palette.
|
||
|
|
||
|
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
|
||
|
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 ``any``, with ties broken in
|
||
|
favor of a more specific ``palette`` (the only "tie" really possible here is when
|
||
|
`any-<scheme>` and `<palette>-any` are both available, in which case the latter
|
||
|
will overwrite the former).
|
||
|
'''
|
||
|
app_dir = Path(self.apps_dir, app_name)
|
||
|
|
||
|
scheme = self.resolve_scheme(scheme)
|
||
|
palette = self.resolve_palette(palette)
|
||
|
|
||
|
# now match theme files in order of inc. specificity; for each unique config file
|
||
|
# tail, only the most specific matching file sticks
|
||
|
file_parts = []
|
||
|
app_config_map = self.app_config_map(app_name)
|
||
|
for pathname in app_config_map:
|
||
|
parts = pathname.split('.')
|
||
|
|
||
|
if len(parts) < 2:
|
||
|
print(f'Filename "{filename}" incorrectly formatted, ignoring')
|
||
|
continue
|
||
|
|
||
|
theme_part, conf_part = parts[0], '.'.join(parts[1:])
|
||
|
file_parts.append((theme_part, conf_part, pathname))
|
||
|
|
||
|
theme_prefixes = [
|
||
|
'any-any',
|
||
|
f'any-{scheme}',
|
||
|
f'{palette}-any',
|
||
|
f'{palette}-{scheme}'
|
||
|
]
|
||
|
|
||
|
matching_file_map = {}
|
||
|
for theme_prefix in theme_prefixes:
|
||
|
for theme_part, conf_part, pathname in file_parts:
|
||
|
if theme_part == theme_prefix:
|
||
|
matching_file_map[conf_part] = app_config_map[pathname]
|
||
|
|
||
|
return matching_file_map
|
||
|
|
||
|
def get_matching_scripts(
|
||
|
self,
|
||
|
app_name,
|
||
|
scheme='any',
|
||
|
palette='any',
|
||
|
):
|
||
|
'''
|
||
|
Execute matching scripts in the app's ``call/`` directory.
|
||
|
|
||
|
Scripts need to be placed in
|
||
|
|
||
|
```sh
|
||
|
<config_dir>/apps/<app_name>/call/<palette>-<scheme>.sh
|
||
|
```
|
||
|
|
||
|
and are matched using the same heuristic employed by config file symlinking
|
||
|
procedure (see ``get_matching_configs()``).
|
||
|
'''
|
||
|
app_dir = Path(self.apps_dir, app_name)
|
||
|
call_dir = Path(app_dir, 'call')
|
||
|
|
||
|
if not call_dir.is_dir():
|
||
|
return
|
||
|
|
||
|
theme_prefixes = [
|
||
|
'any-any',
|
||
|
f'any-{scheme}',
|
||
|
f'{palette}-any',
|
||
|
f'{palette}-{scheme}'
|
||
|
]
|
||
|
|
||
|
# do it this way to keep order for downstream exec
|
||
|
script_list = []
|
||
|
for theme_prefix in theme_prefixes:
|
||
|
for script_path in call_dir.iterdir():
|
||
|
theme_part = script_path.stem
|
||
|
if theme_part == theme_prefix:
|
||
|
script_list.append(script_path)
|
||
|
|
||
|
return list(set(script_list))
|
||
|
|
||
|
def update_app_config(
|
||
|
self,
|
||
|
app_name,
|
||
|
app_settings = None,
|
||
|
scheme = 'any',
|
||
|
palette = 'any',
|
||
|
):
|
||
|
'''
|
||
|
Perform full app config update process, applying symlinks and running scripts.
|
||
|
|
||
|
Note that this explicitly accepts app settings to override or act in place of
|
||
|
missing app details in the app registry file. This is mostly to provide more
|
||
|
programmatic control and test settings without needing them present in the
|
||
|
registry file. The ``update_apps()`` method, however, **will** more strictly
|
||
|
filter out those apps not in the registry, accepting a list of app keys that
|
||
|
ultimately call this method.
|
||
|
|
||
|
Note: symlinks point **from** the target location **to** the known internal config
|
||
|
file; can be a little confusing.
|
||
|
'''
|
||
|
if app_settings is None:
|
||
|
app_settings = self.app_registry.get(app_name, {})
|
||
|
|
||
|
if 'config_dir' in app_settings and 'config_map' in app_settings:
|
||
|
print(f'App "{app_name}" incorrectly configured, skipping')
|
||
|
return
|
||
|
|
||
|
to_symlink: list[tuple[Path, Path]] = []
|
||
|
file_map = self.get_matching_configs(
|
||
|
app_name,
|
||
|
scheme=scheme,
|
||
|
palette=palette,
|
||
|
)
|
||
|
if 'config_dir' in app_settings:
|
||
|
for config_tail, full_path in file_map.items():
|
||
|
to_symlink.append((
|
||
|
util.absolute_path(Path(app_settings['config_dir'], config_tail)), # point from real config dir
|
||
|
full_path, # 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']:
|
||
|
to_symlink.append((
|
||
|
abs_pat(Path(app_settings['config_map'][config_tail])), # point from real config path
|
||
|
full_path, # 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
|
||
|
# TODO: store the status of this cmd & print with the messages
|
||
|
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
|
||
|
)
|
||
|
|
||
|
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}')
|
||
|
|
||
|
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(
|
||
|
self,
|
||
|
apps: str | list[str] = '*',
|
||
|
scheme = 'any',
|
||
|
palette = 'any',
|
||
|
):
|
||
|
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 app_list if a in app_registry]
|
||
|
|
||
|
if not app_list:
|
||
|
print(f'None of the apps "apps" are registered, exiting')
|
||
|
return
|
||
|
|
||
|
for app_name in app_list:
|
||
|
self.update_app_config(
|
||
|
app_name,
|
||
|
app_settings=app_registry[app_name],
|
||
|
scheme=scheme,
|
||
|
palette=palette,
|
||
|
)
|