complete first revision of large-scale refactor, vastly simplified config model

This commit is contained in:
2024-07-03 01:38:52 -07:00
parent 888bb52d23
commit c50a3ec474
30 changed files with 756 additions and 456 deletions

View File

@@ -0,0 +1 @@
from autoconf.config import ConfigManager

View File

@@ -1,22 +1,101 @@
import argparse
from gen_theme import add_gen_subparser
from set_theme import add_set_subparser
import util
#from gen_theme import generate_theme_files
from autoconf.config import ConfigManager
def add_set_subparser(subparsers):
def update_app_settings(args):
cm = ConfigManager(args.config_dif)
cm.update_apps(
apps=args.apps,
scheme=args.scheme,
palette=args.palette,
)
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".'
)
parser.add_argument(
'-a', '--apps',
required = False,
default = "any",
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_app_settings)
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).'
)
parser.add_argument(
'-a', '--app',
required=True,
help='Application target for theme. Supported: ["kitty"]'
)
parser.add_argument(
'-p', '--palette',
required=True,
help='Palette to use for template mappings. Uses local "theme/<palette>/colors.json".'
)
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.'
)
parser.add_argument(
'-o', '--output',
default=None,
help='Output file path for theme. If omitted, app\'s default theme output path is used.'
)
parser.set_defaults(func=generate_theme_files)
# central argparse entry point
parser = argparse.ArgumentParser(
'autoconf',
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(
'-c', '--config-dir',
default = util.xdg_config_path(),
type = util.absolute_path,
help = 'Path to config directory'
)
# add subparsers
subparsers = parser.get_subparsers()
add_gen_subparser(subparsers)
#add_gen_subparser(subparsers)
add_set_subparser(subparsers)
args = parser.parse_args()
if __name__ == '__main__':
args = parser.parse_args()
if 'func' in args:
args.func(args)
else:

370
autoconf/config.py Normal file
View File

@@ -0,0 +1,370 @@
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,
)

View File

@@ -1,146 +0,0 @@
import argparse
import inspect
import subprocess
import json
import os
import tomllib as toml
from pathlib import Path
from colorama import Fore
def add_set_subparser(subparsers):
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=True,
help='Palette name, must match a folder in themes/'
)
parser.add_argument(
'-s', '--scheme',
required=True,
help='Preferred lightness scheme, either "light" or "dark".'
)
parser.add_argument(
'-a', '--app',
required=True,
help='Application target for theme. App must be present in the registry. ' \
+ 'Use "*" to apply to all registered apps'
)
parser.set_defaults(func=update_theme_settings)
def get_running_path():
calling_module = inspect.getmodule(inspect.stack()[-1][0])
return Path(calling_module.__file__).parent
def os_scheme_settings(scheme):
'''
Groups of settings/commands to invoke globally based on the provided `scheme`. This
may control things like default app light/dark behavior, for instance.
'''
os_cmd_groups = {
'Linux': [
"gsettings set org.gnome.desktop.interface color-scheme 'prefer-{scheme}'",
],
'Darwin': [],
}
if scheme not in ['light', 'dark']: return
osname = os.uname().sysname
cmd_list = os_cmd_groups.get(osname, [])
for cmd in cmd_list:
subprocess.check_call(cmd.format(scheme=scheme).split())
def update_theme_settings():
osname = os.uname().sysname
basepath = get_running_path()
app_registry = toml.load(Path(basepath, 'app_registry.toml').open('rb'))
app_registry = app_registry.get('app', {})
if args.app not in app_registry and args.app != '*':
print(f'App {args.app} not registered, exiting')
return
app_list = []
if args.app == '*':
app_list = list(app_registry.items())
else:
app_list = [(args.app, app_registry[args.app])]
links_succ = {}
links_fail = {}
for app_name, app_settings in app_list:
config_dir = Path(app_settings['config_dir']).expanduser()
config_file = app_settings['config_file']
config_path = Path(config_dir, config_file)
if osname not in app_settings['supported_oses']:
print(f'OS [{osname}] not support for app [{app_name}]')
continue
if app_settings['external_theme']:
# symlink from "current-theme.conf" in app's config-dir ...
from_conf_path = Path(config_dir, 'current-theme.conf')
# ... to appropriate generated theme path here in autoconf
to_conf_path = Path(
basepath,
f'themes/{args.palette}/apps/{app_name}/generated/{args.scheme}.conf'
)
else:
# symlink from the canonical config file ...
from_conf_path = config_path
# ... to appropriate theme variant
to_conf_path = Path(
config_dir,
f'{app_name}-{args.palette}-{args.scheme}{config_path.suffix}'
)
if not to_conf_path.exists():
print(
f'Expected symlink target [{to_conf_path}] doesn\'t exist, skipping'
)
links_fail[app_name] = (from_conf_path.name, to_conf_path.name)
continue
# if config file being symlinked exists & isn't already a symlink (i.e.,
# previously set by this script), throw an error.
if from_conf_path.exists() and not from_conf_path.is_symlink():
print(
f'Symlink origin [{from_conf_path}] exists and isn\'t a symlink; please ' \
+ 'first manually remove this file so this script can set the symlink.'
)
links_fail[app_name] = (from_conf_path.name, to_conf_path.name)
continue
else:
# if path doesn't exist, or exists and is symlink, remove the symlink in
# preparation for the new symlink setting
from_conf_path.unlink(missing_ok=True)
print(f'Linking [{from_conf_path}] -> [{to_conf_path}]')
# run color scheme live-reload for app, if available
# TODO: store the status of this cmd & print with the messages
if 'refresh_cmd' in app_settings:
subprocess.check_call(app_settings['refresh_cmd'], shell=True)
from_conf_path.symlink_to(to_conf_path)
links_succ[app_name] = (from_conf_path.name, to_conf_path.name)
for app, (from_p, to_p) in links_succ.items():
print(Fore.GREEN + f'> {app} :: {from_p} -> {to_p}')
for app, (from_p, to_p) in links_fail.items():
print(Fore.RED + f'> {app} :: {from_p} -> {to_p}')
if __name__ == '__main__':
os_scheme_settings(args.scheme)
update_theme_settings()

View File

@@ -4,41 +4,6 @@ import json
import tomllib as toml
from pathlib import Path
def get_running_path():
calling_module = inspect.getmodule(inspect.stack()[-1][0])
return Path(calling_module.__file__).parent
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).'
)
parser.add_argument(
'-a', '--app',
required=True,
help='Application target for theme. Supported: ["kitty"]'
)
parser.add_argument(
'-p', '--palette',
required=True,
help='Palette to use for template mappings. Uses local "theme/<palette>/colors.json".'
)
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.'
)
parser.add_argument(
'-o', '--output',
default=None,
help='Output file path for theme. If omitted, app\'s default theme output path is used.'
)
parser.set_defaults(func=generate_theme_files)
# separation sequences to use base on app
app_sep_map = {

View File

@@ -1,21 +0,0 @@
background #1b1b1b
foreground #c6c6c6
selection_background #303030
selection_foreground #ababab
cursor #d4d4d4
color0 #262626
color8 #262626
color1 #ed625d
color9 #ed625d
color2 #34a543
color10 #34a543
color3 #a68f03
color11 #a68f03
color4 #618eff
color12 #618eff
color5 #ed625d
color13 #ed625d
color6 #618eff
color14 #618eff
color7 #d4d4d4
color15 #d4d4d4

View File

@@ -1,21 +0,0 @@
background #f1f1f1
foreground #262626
selection_background #ababab
selection_foreground #303030
cursor #262626
color0 #1b1b1b
color8 #1b1b1b
color1 #c25244
color9 #c25244
color2 #298835
color10 #298835
color3 #897600
color11 #897600
color4 #4e75d4
color12 #4e75d4
color5 #c25244
color13 #c25244
color6 #4e75d4
color14 #4e75d4
color7 #d4d4d4
color15 #d4d4d4

View File

@@ -1,40 +0,0 @@
# base settings
background = 'black.bg+1'
foreground = 'black.fg+3'
selection_background = 'black.bg+3'
selection_foreground = 'black.m+4'
cursor = 'black.fg+2'
# black
color0 = 'black.bg+2'
color8 = 'black.bg+2'
# red
color1 = 'red.m+2'
color9 = 'red.m+2'
# green
color2 = 'green.m+2'
color10 = 'green.m+2'
# yellow
color3 = 'yellow.m+2'
color11 = 'yellow.m+2'
# blue
color4 = 'blue.m+2'
color12 = 'blue.m+2'
# purple (red)
color5 = 'red.m+2'
color13 = 'red.m+2'
# cyan (blue)
color6 = 'blue.m+2'
color14 = 'blue.m+2'
## white
color7 = 'black.fg+2'
color15 = 'black.fg+2'

View File

@@ -1,40 +0,0 @@
# base settings
background = 'black.fg+1'
foreground = 'black.bg+2'
selection_background = 'black.m+4'
selection_foreground = 'black.bg+3'
cursor = 'black.bg+2'
# black
color0 = 'black.bg+1'
color8 = 'black.bg+1'
# red
color1 = 'red.m+0'
color9 = 'red.m+0'
# green
color2 = 'green.m+0'
color10 = 'green.m+0'
# yellow
color3 = 'yellow.m+0'
color11 = 'yellow.m+0'
# blue
color4 = 'blue.m+0'
color12 = 'blue.m+0'
# purple (red)
color5 = 'red.m+0'
color13 = 'red.m+0'
# cyan (blue)
color6 = 'blue.m+0'
color14 = 'blue.m+0'
## white
color7 = 'black.fg+2'
color15 = 'black.fg+2'

View File

@@ -1,107 +0,0 @@
{
"red": {
"bg+0": "#1c0d0a",
"bg+1": "#2d1412",
"bg+2": "#401a17",
"bg+3": "#531f1d",
"bg+4": "#652624",
"m-4": "#782d2b",
"m-3": "#8b3633",
"m-2": "#9d3e3b",
"m-1": "#b14643",
"m+0": "#c25244",
"m+1": "#d95854",
"m+2": "#ed625d",
"m+3": "#f27870",
"m+4": "#f68c83",
"fg+4": "#faa097",
"fg+3": "#fdb3ab",
"fg+2": "#fec6c0",
"fg+1": "#ffdad3",
"fg+0": "#ffede9"
},
"yellow": {
"bg+0": "#151100",
"bg+1": "#211b00",
"bg+2": "#2d2500",
"bg+3": "#393000",
"bg+4": "#453b00",
"m-4": "#524600",
"m-3": "#5f5100",
"m-2": "#6d5d00",
"m-1": "#7a6900",
"m+0": "#897600",
"m+1": "#978200",
"m+2": "#a68f03",
"m+3": "#b29c34",
"m+4": "#bfaa53",
"fg+4": "#cbb770",
"fg+3": "#d6c58c",
"fg+2": "#e1d3a9",
"fg+1": "#ebe2c5",
"fg+0": "#f5f0e2"
},
"green": {
"bg+0": "#081406",
"bg+1": "#0e1f0c",
"bg+2": "#0f2c10",
"bg+3": "#103814",
"bg+4": "#114518",
"m-4": "#15521d",
"m-3": "#1a5f23",
"m-2": "#1e6c29",
"m-1": "#247a2f",
"m+0": "#298835",
"m+1": "#2e973b",
"m+2": "#34a543",
"m+3": "#56b15a",
"m+4": "#71bc72",
"fg+4": "#8ac789",
"fg+3": "#a2d3a0",
"fg+2": "#badeb8",
"fg+1": "#d1e9cf",
"fg+0": "#e8f4e7"
},
"blue": {
"bg+0": "#0f101b",
"bg+1": "#151b2f",
"bg+2": "#1a2544",
"bg+3": "#202f59",
"bg+4": "#263a6e",
"m-4": "#2d4582",
"m-3": "#355196",
"m-2": "#3d5daa",
"m-1": "#4669bf",
"m+0": "#4e75d4",
"m+1": "#5781ea",
"m+2": "#618eff",
"m+3": "#7b9bff",
"m+4": "#91a9ff",
"fg+4": "#a6b7ff",
"fg+3": "#b9c5ff",
"fg+2": "#cbd3ff",
"fg+1": "#dde1ff",
"fg+0": "#eef0fe"
},
"black": {
"bg+0": "#111111",
"bg+1": "#1b1b1b",
"bg+2": "#262626",
"bg+3": "#303030",
"bg+4": "#3b3b3b",
"m-4": "#474747",
"m-3": "#525252",
"m-2": "#5e5e5e",
"m-1": "#6a6a6a",
"m+0": "#777777",
"m+1": "#848484",
"m+2": "#919191",
"m+3": "#9e9e9e",
"m+4": "#ababab",
"fg+4": "#b9b9b9",
"fg+3": "#c6c6c6",
"fg+2": "#d4d4d4",
"fg+1": "#e2e2e2",
"fg+0": "#f1f1f1"
}
}

9
autoconf/util.py Normal file
View File

@@ -0,0 +1,9 @@
from pathlib import Path
from xdg import BaseDirectory
def absolute_path(path: str | Path) -> Path:
return Path(path).expanduser().absolute()
def xdg_config_path():
return Path(BaseDirectory.save_config_path('autoconf'))