767 lines
31 KiB
Python
767 lines
31 KiB
Python
'''
|
|
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/symconf/apps/user/palette1-light.conf.ini
|
|
palette2-dark.app.conf -> ~/.config/symconf/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; unique path names need to be resolved to unique
|
|
path locations).
|
|
'''
|
|
import os
|
|
import tomllib
|
|
from pathlib import Path
|
|
|
|
from colorama import Fore, Back, Style
|
|
|
|
from symconf import util
|
|
from symconf.util import printc, color_text
|
|
|
|
from symconf.runner import Runner
|
|
from symconf.template import FileTemplate, TOMLTemplate
|
|
from symconf.matching import Matcher, FilePart
|
|
|
|
|
|
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/symconf/"``.
|
|
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.group_dir = Path(self.config_dir, 'groups')
|
|
|
|
self.app_registry = {}
|
|
self.matcher = Matcher()
|
|
self.runner = Runner()
|
|
|
|
self._check_dirs()
|
|
if not disable_registry:
|
|
self._check_registry()
|
|
|
|
def _check_dirs(self):
|
|
'''
|
|
Check necessary config directories for existence.
|
|
|
|
Regardless of programmatic use or ``disable_registry``, we need 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):
|
|
'''
|
|
Check the existence and format of the registry file
|
|
``<config_dir>/app_registry.toml``.
|
|
|
|
All that's needed to pass the format check is the existence of the key `"app"` in
|
|
the registry dict. If this isn't present, the TOML file is either incorrectly
|
|
configured, or it's empty and there are no apps to operate on.
|
|
'''
|
|
registry_path = Path(self.config_dir, 'app_registry.toml')
|
|
|
|
if not registry_path.exists():
|
|
printc(
|
|
f'No registry file found at expected location "{registry_path}"',
|
|
Fore.YELLOW
|
|
)
|
|
return
|
|
|
|
app_registry = tomllib.load(registry_path.open('rb'))
|
|
|
|
if 'app' not in app_registry:
|
|
printc(
|
|
'Registry file found but is either empty or incorrectly formatted (no "app" key).',
|
|
Fore.YELLOW
|
|
)
|
|
|
|
self.app_registry = app_registry.get('app', {})
|
|
|
|
def _resolve_group(self, group, value='auto'):
|
|
'''
|
|
Resolve group inputs to concrete values.
|
|
|
|
This method is mostly meant to handle values like ``auto`` which can be provided
|
|
by the user, but need to be interpreted in the system context (e.g., either
|
|
resolving to "any" or using the app's currently set option from the cache).
|
|
'''
|
|
if value == 'auto':
|
|
# look group up in app cache and set to current value
|
|
return 'any'
|
|
|
|
return value
|
|
|
|
def _symlink_paths(
|
|
self,
|
|
to_symlink: list[tuple[Path, Path]],
|
|
):
|
|
'''
|
|
Symlink paths safely from target paths to internal config paths
|
|
|
|
This method upholds the consistent symlink model: target locations are only
|
|
symlinked from if they don't exist or are already a symlink. We never overwrite
|
|
any concrete files, preventing accidental deletion of config files. This means
|
|
users must physically delete/move their existing configs into a ``symconf`` config
|
|
directory if they want it to be managed; otherwise, we don't touch it.
|
|
|
|
Parameters:
|
|
to_symlink: path pairs to symlink, from target (external) path to source
|
|
(internal) path
|
|
'''
|
|
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 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():
|
|
printc(
|
|
f'Symlink target "{from_path}" exists and isn\'t a symlink, NOT overwriting; '
|
|
f'please first manually remove this file so a symlink can be set.',
|
|
Fore.RED
|
|
)
|
|
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)
|
|
|
|
# create parent directory if doesn't exist
|
|
from_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
from_path.symlink_to(to_path)
|
|
links_succ.append((from_path, to_path))
|
|
|
|
# link report
|
|
for from_p, to_p in links_succ:
|
|
from_p = util.to_tilde_path(from_p)
|
|
to_p = to_p.relative_to(self.config_dir)
|
|
print(
|
|
color_text("│", Fore.BLUE),
|
|
color_text(
|
|
f' > linked {color_text(from_p,Style.BRIGHT)} -> {color_text(to_p,Style.BRIGHT)}',
|
|
Fore.GREEN
|
|
)
|
|
)
|
|
|
|
for from_p, to_p in links_fail:
|
|
from_p = util.to_tilde_path(from_p)
|
|
to_p = to_p.relative_to(self.config_dir)
|
|
print(
|
|
color_text("│", Fore.BLUE),
|
|
color_text(
|
|
f' > failed to link {from_p} -> {to_p}',
|
|
Fore.RED
|
|
)
|
|
)
|
|
|
|
def _matching_template_groups(
|
|
self,
|
|
scheme = 'auto',
|
|
style = 'auto',
|
|
**kw_groups,
|
|
) -> tuple[dict, list[FilePart]]:
|
|
'''
|
|
Find matching template files for provided template groups.
|
|
|
|
For template groups other than "scheme" and "style," this method performs a
|
|
basic search for matching filenames in the respective group directory. For
|
|
example, a KW group like ``font = "mono"`` would look for ``font/mono.toml`` (as
|
|
well as the relaxation ``font/none.toml``). These template TOML files are stacked
|
|
and ultimately presented to downstream config templates to be filled. Note how
|
|
there is no dependence on the scheme during the filename match (e.g., we don't
|
|
look for ``font/mono-dark.toml``).
|
|
|
|
For "scheme" and "style," we have slightly different behavior, more closely
|
|
aligning with the non-template matching. We don't have "scheme" and "style"
|
|
template folders, but a single "theme" folder, within which we match template
|
|
files just the same as we do for non-template config files. That is, we will look
|
|
for files of the format
|
|
|
|
```sh
|
|
<style>-<scheme>.toml
|
|
```
|
|
|
|
The only difference is that, while ``style`` can still include arbitrary style
|
|
variants, it *must* have the form
|
|
|
|
```sh
|
|
<variant-1>-...-<variant-N>-<palette>
|
|
```
|
|
|
|
if you want to match a ``palette`` template. Palettes are like regular template
|
|
groups, and should be placed in their own template folder. But when applying those
|
|
palette colors, they almost always need to be coupled with a scheme setting (e.g.,
|
|
"solarized-dark"). This is the one place where the templating system allows
|
|
"intermediate templates:" raw palette colors can fill theme templates, which then
|
|
fill user config templates.
|
|
|
|
So in summary: palette files can be used to populate theme templates by providing a
|
|
style string that matches the format ``<variant>-<palette>``. The ``<palette>``
|
|
will be extracted and used to match filenames in the palette template folder. The
|
|
term ``<variant>-<palette>-<scheme>`` will be used to match templates in the theme
|
|
folder, where ``<variant>-<palette> = <style>`` and ``<scheme>`` are independently
|
|
specifiable with supported for ``auto``, ``none``, etc.
|
|
|
|
Note that "strictness" doesn't really apply in this setting. In the non-template
|
|
config matching setting, setting strict means there's no relaxation to "none," but
|
|
here, any "none" group template 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 by specifying that directly.
|
|
``get_matching_scripts()`` is similar in this sense.
|
|
'''
|
|
scheme = self._resolve_group('scheme', scheme)
|
|
style = self._resolve_group('style', style)
|
|
|
|
groups = {
|
|
k : self._resolve_group(k, v)
|
|
for k, v in kw_groups.items()
|
|
}
|
|
|
|
if not self.group_dir.exists():
|
|
return {}, []
|
|
|
|
# palette lookup will behave like other groups; strip it out of the `style` string
|
|
# and it to the keyword groups to be searched regularly (but only if the palette
|
|
# group exists)
|
|
if Path(self.group_dir, 'palette').exists():
|
|
palette = style.split('-')[-1]
|
|
groups['palette'] = palette
|
|
|
|
# handle individual groups (not part of joint style-scheme)
|
|
group_matches = {}
|
|
for fkey, fval in groups.items():
|
|
key_group_dir = Path(self.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()}
|
|
|
|
# 1) establish prefix order
|
|
if fval == 'any':
|
|
prefix_order = [fval, 'none']
|
|
else:
|
|
prefix_order = ['none', fval]
|
|
|
|
# 2) fully enumerate matches, including "any"
|
|
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
|
|
|
|
# 3) select best matches; done in a loop to smooth the logic, else we'd need
|
|
# to check if the last match is "none," and if not, find out if it was
|
|
# available. This alone makes the following loop more easy to follow: walk
|
|
# through full enumeration, and if it's the target match or "none," take the
|
|
# file, nicely handling the fact those may both be the same.
|
|
#
|
|
# also 4) uniqueness happening here
|
|
match_dict = {}
|
|
target = matches[-1] # select best based on order, make new target
|
|
for stem in matches:
|
|
if stem == target 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. This is the one template group that gets nested
|
|
# treatment
|
|
palette_dict = TOMLTemplate.stack_toml(group_matches.get('palette', []))
|
|
|
|
# then palette-scheme groups (require 2-combo logic)
|
|
theme_matches = []
|
|
theme_group_dir = Path(self.group_dir, 'theme')
|
|
|
|
if theme_group_dir.exists():
|
|
theme_matches = self.matcher.match_paths(
|
|
theme_group_dir.iterdir(), # match files in groups/theme/
|
|
self.matcher.prefix_order(scheme, style) # reg non-template order
|
|
)
|
|
|
|
# 5) final match relaxation
|
|
relaxed_theme_matches = self.matcher.relaxed_match(theme_matches)
|
|
|
|
theme_dict = {}
|
|
for file_part in relaxed_theme_matches:
|
|
toml_dict = TOMLTemplate(file_part.path).fill(palette_dict)
|
|
theme_dict = util.deep_update(theme_dict, toml_dict)
|
|
|
|
template_dict = {
|
|
group : TOMLTemplate.stack_toml(ordered_matches)
|
|
for group, ordered_matches in group_matches.items()
|
|
}
|
|
template_dict['theme'] = theme_dict
|
|
|
|
return template_dict, relaxed_theme_matches
|
|
|
|
def get_matching_configs(
|
|
self,
|
|
app_name,
|
|
scheme = 'auto',
|
|
style = 'auto',
|
|
strict = False,
|
|
) -> dict[str, FilePart]:
|
|
'''
|
|
Get user-provided app config files that match the provided scheme and style
|
|
specifications.
|
|
|
|
Unique config file path names are written to the file map in order of specificity.
|
|
All config files follow the naming scheme ``<style>-<scheme>.<path-name>``,
|
|
where ``<style>-<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 ``none``, with ties broken in
|
|
favor of a more specific ``style`` (the only "tie" really possible here is when
|
|
``none-<scheme>`` and ``<style>-none`` are both available, in which case the latter
|
|
will overwrite the former).
|
|
|
|
.. admonition: Edge cases
|
|
|
|
There are a few quirks to this matching scheme that yield potentially
|
|
unintuitive results. As a recap:
|
|
|
|
- The "theme part" of a config file name includes both a style (palette and
|
|
more) and a scheme component. Either of those parts may be "none," which
|
|
simply indicates that that particular file does not attempt to change that
|
|
factor. "none-light," for instance, might simply set a light background,
|
|
having no effect on other theme settings.
|
|
- Non-keyword queries for scheme and style will always be matched exactly.
|
|
However, if an exact match is not available, we also look for "none" in each
|
|
component's place. For example, if we wanted to set "solarized-light" but
|
|
only "none-light" was available, it would still be set because we can still
|
|
satisfy the desire scheme (light). The same goes for the style
|
|
specification, and if neither match, "none-none" will always be matched if
|
|
available. Note that if "none" is specified exactly, it will be matched
|
|
exactly, just like any other value.
|
|
- During a query, "any" may also be specified for either component, indicating
|
|
we're okay to match any file's text for that part. For example, if I have
|
|
two config files ``"p1-dark"`` and ``"p2-dark"``, the query for ``("any",
|
|
"dark")`` would suggest I'd like the dark scheme but am okay with either
|
|
style.
|
|
|
|
It's under the "any" keyword where possibly counter-intuitive results may come
|
|
about. Specifying "any" does not change the mechanism that seeks to optionally
|
|
match "none" if no specific match is available. For example, suppose we have
|
|
the config file ``red-none`` (setting red colors regardless of a light/dark
|
|
mode). If I query for ``("any", "dark")``, ``red-none`` will be matched
|
|
(supposing there are no more direct matches available). Because we don't a
|
|
match specifically for the scheme "dark," it gets relaxed to "none." But we
|
|
indicated we're okay to match any style. So despite asking for a config that
|
|
sets a dark scheme and not caring about the style, we end up with a config
|
|
that explicitly does nothing about the scheme but sets a particular style.
|
|
This matching process is still consistent with what we expect the keywords to
|
|
do, it just slightly muddies the waters with regard to what can be matched
|
|
(mostly due to the amount that's happening under the hood here).
|
|
|
|
This example is the primary driver behind the optional ``strict`` setting,
|
|
which in this case would force the dark scheme to be matched (and ultimately
|
|
find no matches).
|
|
|
|
Also: when "any" is used for a component, options with "none" are prioritized,
|
|
allowing "any" to be as flexible and unassuming as possible (only matching a
|
|
random specific config among the options if there is no "none" available).
|
|
|
|
Returns:
|
|
Dictionary
|
|
'''
|
|
user_app_dir = Path(self.apps_dir, app_name, 'user')
|
|
|
|
paths = []
|
|
if user_app_dir.is_dir():
|
|
paths = user_app_dir.iterdir()
|
|
|
|
# 1) establish prefix order
|
|
prefix_order = self.matcher.prefix_order(
|
|
self._resolve_group('scheme', scheme),
|
|
self._resolve_group('style', style),
|
|
strict=strict
|
|
)
|
|
|
|
# 2) match enumeration
|
|
ordered_matches = self.matcher.match_paths(paths, prefix_order)
|
|
|
|
# 3) make unique (by pathname)
|
|
matching_file_map = {
|
|
file_part.conf : file_part
|
|
for file_part in ordered_matches
|
|
}
|
|
|
|
return matching_file_map
|
|
|
|
def get_matching_templates(
|
|
self,
|
|
app_name,
|
|
scheme='auto',
|
|
style='auto',
|
|
**kw_groups,
|
|
) -> tuple[dict[str, Path], dict, list[FilePart], int]:
|
|
template_dict, theme_matches = self._matching_template_groups(
|
|
scheme=scheme,
|
|
style=style,
|
|
**kw_groups,
|
|
)
|
|
|
|
max_idx = 0
|
|
if theme_matches:
|
|
max_idx = max([fp.index for fp in theme_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, theme_matches, max_idx
|
|
|
|
def get_matching_scripts(
|
|
self,
|
|
app_name,
|
|
scheme='any',
|
|
style='any',
|
|
) -> list[FilePart]:
|
|
'''
|
|
Execute matching scripts in the app's ``call/`` directory.
|
|
|
|
Scripts need to be placed in
|
|
|
|
```sh
|
|
<config_dir>/apps/<app_name>/call/<style>-<scheme>.sh
|
|
```
|
|
|
|
and are matched using the same heuristic employed by config file symlinking
|
|
procedure (see ``get_matching_configs()``), albeit with a forced ``prefix_order``,
|
|
ordered by increasing specificity. The order is then reversed, and the final list
|
|
orders the scripts by the first time they appear (intention being to reload
|
|
specific settings first).
|
|
|
|
TODO: consider running just the most specific script? Users might want to design
|
|
their scripts to be stackable, or they may just be independent.
|
|
'''
|
|
app_dir = Path(self.apps_dir, app_name)
|
|
call_dir = Path(app_dir, 'call')
|
|
|
|
if not call_dir.is_dir():
|
|
return []
|
|
|
|
prefix_order = [
|
|
('none', 'none'),
|
|
('none', scheme),
|
|
(style, 'none'),
|
|
(style, scheme),
|
|
]
|
|
|
|
script_matches = self.matcher.match_paths(
|
|
call_dir.iterdir(),
|
|
prefix_order=prefix_order
|
|
)
|
|
relaxed_matches = self.matcher.relaxed_match(script_matches)
|
|
|
|
# flip list to execute by decreasing specificity
|
|
return relaxed_matches[::-1]
|
|
|
|
def update_app_config(
|
|
self,
|
|
app_name : str,
|
|
app_settings : dict = None,
|
|
scheme : str = 'any',
|
|
style : str = 'any',
|
|
strict : bool = False,
|
|
**kw_groups,
|
|
):
|
|
'''
|
|
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.
|
|
|
|
.. admonition:: Logic overview
|
|
|
|
This method is the center point of the ConfigManager class. It unifies the
|
|
user and template matching, file generation, setting of symlinks, and running
|
|
of scripts. At a high level,
|
|
|
|
1. An app name (e.g., kitty), app settings (e.g., a ``config_dir`` or
|
|
``config_map``), scheme (e.g., "dark"), and style (e.g., "soft-gruvbox")
|
|
2. Get matching user config files via ``get_matching_configs()``
|
|
3. Get matching template config files and the aggregate template dict via
|
|
``get_matching_templates()``
|
|
4. Interleave the two result sets by pathname and match quality. Template
|
|
matches are preferred in the case of tied scores. This resolves any
|
|
pathname clashes across matching files.
|
|
|
|
This is a particularly important step. It compares concrete config names
|
|
explicitly provided by the user (e.g., ``soft-gruvbox-dark.kitty.conf``)
|
|
with named TOML files in a group directory (e.g,.
|
|
``theme/soft-gruvbox-dark.toml``). We have to determine whether the
|
|
available templates constitute a better match than the best user option,
|
|
which is done by comparing the level in the prefix order (the index)
|
|
where the match takes place.
|
|
|
|
Templates are generally more flexible, and other keywords may also provide
|
|
a matching template group (e.g., ``-T font=mono`` to match some
|
|
font-specific settings). When the match is otherwise equally good (e.g.,
|
|
both style and scheme match directly), we prefer the template due to its
|
|
general portability and likelihood of being more up-to-date. We also don't
|
|
explicitly use the fact auxiliary template groups might be matched by the
|
|
user's input: we only compare the user and template configs on the basis of
|
|
the quality of the style-scheme match. This effectively means additional
|
|
template groups (e.g., font) don't "count" if the basis style-scheme
|
|
doesn't win over a user config file. There could be an arbitrary number of
|
|
other template group matches, but they don't contribute to the match
|
|
quality. For instance, a concrete user config ``solarized-dark.kitty.conf``
|
|
will be selected over ``solarized-none.toml`` plus 10 other matching theme
|
|
elements if the user asked for ``-s dark -t solarized``.
|
|
5. For those template matches, fill/generate the template file and place it in
|
|
the app's ``generated/`` directory.
|
|
|
|
Parameters:
|
|
app_name: name of the app whose config files should be updated
|
|
app_settings: dict of app settings (i.e., ``config_dir`` or ``config_map``)
|
|
scheme: scheme spec
|
|
style: style spec
|
|
strict: whether to match ``scheme`` and ``style`` strictly
|
|
'''
|
|
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
|
|
|
|
# match both user configs and templates
|
|
# -> "*_map" are dicts from config pathnames to FilePart / Paths
|
|
config_map = self.get_matching_configs(
|
|
app_name,
|
|
scheme=scheme,
|
|
style=style,
|
|
strict=strict,
|
|
)
|
|
template_map, template_dict, theme_matches, tidx = self.get_matching_templates(
|
|
app_name,
|
|
scheme=scheme,
|
|
style=style,
|
|
**kw_groups
|
|
)
|
|
|
|
# create "generated" directory for the app
|
|
generated_path = Path(self.apps_dir, app_name, 'generated')
|
|
generated_path.mkdir(parents=True, exist_ok=True)
|
|
|
|
# track selected configs with a pathname -> fullpath map
|
|
final_config_map = {}
|
|
# tracker for template configs that were generated
|
|
generated_config = []
|
|
|
|
# interleave user and template matches
|
|
for pathname, full_path in template_map.items():
|
|
if pathname in config_map and config_map[pathname].index > tidx:
|
|
final_config_map[pathname] = config_map[pathname].path
|
|
else:
|
|
config_path = Path(generated_path, pathname)
|
|
config_path.write_text(
|
|
FileTemplate(full_path).fill(template_dict)
|
|
)
|
|
final_config_map[pathname] = config_path
|
|
generated_config.append(pathname)
|
|
|
|
# fill in any config matches not added to final_config_map above
|
|
for pathname, file_part in config_map.items():
|
|
if pathname not in final_config_map:
|
|
final_config_map[pathname] = file_part.path
|
|
|
|
# prepare symlinks (inverse loop and conditional order is sloppier)
|
|
to_symlink: list[tuple[Path, Path]] = []
|
|
if 'config_dir' in app_settings:
|
|
config_dir = util.absolute_path(app_settings['config_dir'])
|
|
for ext_pathname, int_fullpath in final_config_map.items():
|
|
ext_fullpath = Path(config_dir, ext_pathname)
|
|
to_symlink.append((
|
|
ext_fullpath, # point from external config dir
|
|
int_fullpath, # to internal config location
|
|
))
|
|
elif 'config_map' in app_settings:
|
|
for ext_pathname, int_fullpath in final_config_map.items():
|
|
# app's config map points config pathnames to absolute paths
|
|
if ext_pathname in app_settings['config_map']:
|
|
ext_fullpath = util.absolute_path(app_settings['config_map'][ext_pathname])
|
|
to_symlink.append((
|
|
ext_fullpath, # point from external config path
|
|
int_fullpath, # to internal config location
|
|
))
|
|
|
|
# run matching scripts for app-specific reload
|
|
script_list = self.get_matching_scripts(
|
|
app_name,
|
|
scheme=scheme,
|
|
style=style,
|
|
)
|
|
script_list = list(map(lambda f:f.path, script_list))
|
|
|
|
# print match messages
|
|
num_links = len(to_symlink)
|
|
num_scripts = len(script_list)
|
|
print(
|
|
color_text("├─", Fore.BLUE),
|
|
f'{app_name} :: matched ({num_links}) config files and ({num_scripts}) scripts'
|
|
)
|
|
|
|
rel_theme_matches = ' < '.join([
|
|
str(fp.path.relative_to(self.group_dir))
|
|
for fp in theme_matches
|
|
])
|
|
for pathname in generated_config:
|
|
print(
|
|
color_text("│", Fore.BLUE),
|
|
color_text(
|
|
f' > generating config "{pathname}" from [{rel_theme_matches}]',
|
|
Style.DIM
|
|
)
|
|
)
|
|
|
|
self._symlink_paths(to_symlink)
|
|
self.runner.run_many(script_list)
|
|
|
|
def configure_apps(
|
|
self,
|
|
apps : str | list[str] = '*',
|
|
scheme : str = 'any',
|
|
style : str = 'any',
|
|
strict : bool = False,
|
|
**kw_groups,
|
|
):
|
|
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 apps if a in self.app_registry]
|
|
|
|
if not app_list:
|
|
print(f'None of the apps "{apps}" are registered, exiting')
|
|
return
|
|
|
|
print(f'> symconf parameters: ')
|
|
print(f' > registered apps :: {color_text(app_list, Fore.YELLOW)}')
|
|
print(f' > style :: {color_text(style, Fore.YELLOW)}')
|
|
print(f' > scheme :: {color_text(scheme, Fore.YELLOW)}\n')
|
|
|
|
for app_name in app_list:
|
|
app_dir = Path(self.apps_dir, app_name)
|
|
if not app_dir.exists():
|
|
# app has no directory, skip it
|
|
continue
|
|
|
|
self.update_app_config(
|
|
app_name,
|
|
app_settings=self.app_registry[app_name],
|
|
scheme=scheme,
|
|
style=style,
|
|
strict=False,
|
|
**kw_groups,
|
|
)
|
|
|
|
def _app_action(
|
|
self,
|
|
script_pathname,
|
|
apps: str | list[str] = '*',
|
|
):
|
|
'''
|
|
Execute a static script-based action for a provided set of apps.
|
|
|
|
Mostly a helper method for install and update actions, calling a static script
|
|
name under each app's directory.
|
|
'''
|
|
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 apps if a in self.app_registry]
|
|
|
|
if not app_list:
|
|
print(f'None of the apps "{apps}" are registered, exiting')
|
|
return
|
|
|
|
print(
|
|
f'> symconf parameters: '
|
|
f' > registered apps :: {color_text(app_list, Fore.YELLOW)}'
|
|
)
|
|
|
|
for app_name in app_list:
|
|
target_script = Path(self.apps_dir, app_name, script_pathname)
|
|
if not target_script.exists():
|
|
continue
|
|
|
|
self.runner.run_script(target_script)
|
|
|
|
def install_apps(
|
|
self,
|
|
apps: str | list[str] = '*',
|
|
):
|
|
self._app_action('install.sh', apps)
|
|
|
|
def update_apps(
|
|
self,
|
|
apps: str | list[str] = '*',
|
|
):
|
|
self._app_action('update.sh', apps)
|