large refactor (break up ConfigManager), add more tests
This commit is contained in:
parent
cb1dd52833
commit
bf311d57a5
@ -1,7 +1,8 @@
|
|||||||
# Overview
|
# Overview
|
||||||
`symconf` is a CLI tool for managing local application configuration. It uses a simple
|
`symconf` is a CLI tool for managing local application configuration. It uses a simple
|
||||||
operational model that symlinks centralized config files to their expected locations across
|
operational model that symlinks centralized config files to their expected locations across
|
||||||
the system. This central config directory can then be version controlled.
|
one's system. This central config directory can then be version controlled, and app
|
||||||
|
config files can be updated in one place.
|
||||||
|
|
||||||
`symconf` also facilitates dynamically setting system and application "themes," symlinking
|
`symconf` also facilitates dynamically setting system and application "themes," symlinking
|
||||||
matching theme config files for registered apps and running config reloading scripts.
|
matching theme config files for registered apps and running config reloading scripts.
|
||||||
|
2
TODO.md
2
TODO.md
@ -1 +1,3 @@
|
|||||||
- Add local app caching for `auto` logic
|
- Add local app caching for `auto` logic
|
||||||
|
- Better script handling: 1) should be live, basically reconnecting output to native
|
||||||
|
terminal, 2) catch and print errors from STDERR
|
||||||
|
@ -7,6 +7,14 @@ to apply. The default location for this directory is your `$XDG_CONFIG_HOME` (e.
|
|||||||
`symconf` expects you to create two top-level components in your config directory: an
|
`symconf` expects you to create two top-level components in your config directory: an
|
||||||
`apps/` directory and an `app_registry.toml` file.
|
`apps/` directory and an `app_registry.toml` file.
|
||||||
|
|
||||||
|
**High-level view**:
|
||||||
|
|
||||||
|
- `symconf` operates on a single directory that houses all your config files
|
||||||
|
- Config files in this directory must be placed under an `apps/<app-name>/` folder to be
|
||||||
|
associated with the app `<app-name>`
|
||||||
|
- For apps to be visible, you need an `app_registry.toml` file that tells `symconf` where
|
||||||
|
to symlink your files in `apps/`
|
||||||
|
|
||||||
## Apps directory
|
## Apps directory
|
||||||
An `apps/` directory should be created in your config home, with a subdirectory
|
An `apps/` directory should be created in your config home, with a subdirectory
|
||||||
`apps/<app-name>/` for each app with config files that you'd like to be visible to
|
`apps/<app-name>/` for each app with config files that you'd like to be visible to
|
||||||
@ -40,8 +48,8 @@ as follows:
|
|||||||
transparency, etc). Use `none` to indicate that the file does not correspond to any
|
transparency, etc). Use `none` to indicate that the file does not correspond to any
|
||||||
particular style group.
|
particular style group.
|
||||||
- `config-name`: the _name_ of the config file. This should correspond to _same path
|
- `config-name`: the _name_ of the config file. This should correspond to _same path
|
||||||
name_ that is expected by the app. For example, if your app expects a config file at
|
name_ that is expected by the app being configured. For example, if your app expects a
|
||||||
`a/b/c/d.conf`, "`d.conf`" is the path name.
|
config file at `a/b/c/d.conf`, "`d.conf`" is the path name.
|
||||||
|
|
||||||
When invoking `symconf` with specific scheme and palette settings (see more in Usage),
|
When invoking `symconf` with specific scheme and palette settings (see more in Usage),
|
||||||
appropriate config files can be matched based on how you've named your files.
|
appropriate config files can be matched based on how you've named your files.
|
||||||
|
@ -0,0 +1,17 @@
|
|||||||
|
# Matching
|
||||||
|
This file describes the naming and matching scheme employed by `symconf`.
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.config/symconf/
|
||||||
|
├── app_registry.toml
|
||||||
|
└── apps/
|
||||||
|
└── <app>/
|
||||||
|
├── user/ # user managed
|
||||||
|
│ └── none-none.<config-name>
|
||||||
|
├── generated/ # automatically populated
|
||||||
|
│ └── none-none.<config-name>
|
||||||
|
├── templates/ # config templates
|
||||||
|
│ └── none-none.template
|
||||||
|
└── call/ # reload scripts
|
||||||
|
└── none-none.sh
|
||||||
|
```
|
1
sym_tgt/test/aaa
Symbolic link
1
sym_tgt/test/aaa
Symbolic link
@ -0,0 +1 @@
|
|||||||
|
/home/smgr/Documents/projects/olog/symconf/tests/test-config-dir/apps/test/user/none-light.aaa
|
1
sym_tgt/test/ccc
Symbolic link
1
sym_tgt/test/ccc
Symbolic link
@ -0,0 +1 @@
|
|||||||
|
/home/smgr/Documents/projects/olog/symconf/tests/test-config-dir/apps/test/user/test-light.ccc
|
@ -1,7 +1,12 @@
|
|||||||
from symconf.config import ConfigManager
|
from symconf.runner import Runner
|
||||||
from symconf.reader import DictReader
|
from symconf.reader import DictReader
|
||||||
|
from symconf.config import ConfigManager
|
||||||
|
from symconf.matching import Matcher, FilePart
|
||||||
|
from symconf.template import Template, FileTemplate, TOMLTemplate
|
||||||
|
|
||||||
from symconf import config
|
from symconf import config
|
||||||
|
from symconf import matching
|
||||||
from symconf import reader
|
from symconf import reader
|
||||||
|
from symconf import template
|
||||||
from symconf import theme
|
from symconf import theme
|
||||||
from symconf import util
|
from symconf import util
|
||||||
|
@ -43,12 +43,12 @@ def add_update_subparser(subparsers):
|
|||||||
parser.set_defaults(func=update_apps)
|
parser.set_defaults(func=update_apps)
|
||||||
|
|
||||||
def add_config_subparser(subparsers):
|
def add_config_subparser(subparsers):
|
||||||
def config_apps(args):
|
def configure_apps(args):
|
||||||
cm = ConfigManager(args.config_dir)
|
cm = ConfigManager(args.config_dir)
|
||||||
cm.config_apps(
|
cm.configure_apps(
|
||||||
apps=args.apps,
|
apps=args.apps,
|
||||||
scheme=args.scheme,
|
scheme=args.scheme,
|
||||||
palette=args.palette,
|
style=args.palette,
|
||||||
)
|
)
|
||||||
|
|
||||||
parser = subparsers.add_parser(
|
parser = subparsers.add_parser(
|
||||||
@ -82,7 +82,7 @@ def add_config_subparser(subparsers):
|
|||||||
action=util.KVPair,
|
action=util.KVPair,
|
||||||
help='Groups to use when populating templates, in the form group=value'
|
help='Groups to use when populating templates, in the form group=value'
|
||||||
)
|
)
|
||||||
parser.set_defaults(func=config_apps)
|
parser.set_defaults(func=configure_apps)
|
||||||
|
|
||||||
|
|
||||||
# central argparse entry point
|
# central argparse entry point
|
||||||
|
File diff suppressed because it is too large
Load Diff
251
symconf/matching.py
Normal file
251
symconf/matching.py
Normal file
@ -0,0 +1,251 @@
|
|||||||
|
'''
|
||||||
|
Top-level definitions
|
||||||
|
|
||||||
|
Config files are expected to have names matching the following spec:
|
||||||
|
|
||||||
|
<style>-<scheme>.<config_pathname>
|
||||||
|
|
||||||
|
- ``config_pathname``: refers to a concrete filename, typically that which is expected by
|
||||||
|
the target app (e.g., ``kitty.conf``). In the context of ``config_map`` in the registry,
|
||||||
|
however, it merely serves as an identifier, as it can be mapped onto any path.
|
||||||
|
- ``scheme``: indicates the lightness mode ("light" or "dark")
|
||||||
|
- ``style``: general identifier capturing the stylizations applied to the config file.
|
||||||
|
This is typically of the form ``<variant>-<palette>``, i.e., including a reference to a
|
||||||
|
particular color palette.
|
||||||
|
|
||||||
|
For example
|
||||||
|
|
||||||
|
```sh
|
||||||
|
soft-gruvbox-dark.kitty.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
gets mapped to
|
||||||
|
|
||||||
|
```sh
|
||||||
|
style -> "soft-gruvbox"
|
||||||
|
scheme -> "dark"
|
||||||
|
pathname -> "kitty.conf"
|
||||||
|
```
|
||||||
|
'''
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from symconf import util
|
||||||
|
|
||||||
|
|
||||||
|
class FilePart:
|
||||||
|
def __init__(self, path: str | Path):
|
||||||
|
self.path = util.absolute_path(path)
|
||||||
|
self.pathname = self.path.name
|
||||||
|
|
||||||
|
parts = str(self.pathname).split('.')
|
||||||
|
if len(parts) < 2:
|
||||||
|
raise ValueError(f'Filename "{pathname}" incorrectly formatted, ignoring')
|
||||||
|
|
||||||
|
self.theme = parts[0]
|
||||||
|
self.conf = '.'.join(parts[1:])
|
||||||
|
|
||||||
|
theme_split = self.theme.split('-')
|
||||||
|
self.scheme = theme_split[-1]
|
||||||
|
self.style = '-'.join(theme_split[:-1])
|
||||||
|
|
||||||
|
self.index = -1
|
||||||
|
|
||||||
|
def set_index(self, idx: int):
|
||||||
|
self.index = idx
|
||||||
|
|
||||||
|
|
||||||
|
class Matcher:
|
||||||
|
def get_file_parts(
|
||||||
|
self,
|
||||||
|
paths: list[str | Path],
|
||||||
|
) -> list[FilePart]:
|
||||||
|
'''
|
||||||
|
Split pathnames into parts for matching.
|
||||||
|
|
||||||
|
Pathnames should be of the format
|
||||||
|
|
||||||
|
```sh
|
||||||
|
<style>-<scheme>.<config_pathname>
|
||||||
|
```
|
||||||
|
|
||||||
|
where ``style`` is typically itself of the form ``<variant>-<palette>``.
|
||||||
|
'''
|
||||||
|
file_parts = []
|
||||||
|
for path in paths:
|
||||||
|
try:
|
||||||
|
config_file = FilePart(path)
|
||||||
|
file_parts.append(config_file)
|
||||||
|
except ValueError as e:
|
||||||
|
print(f'Filename "{pathname}" incorrectly formatted, ignoring')
|
||||||
|
|
||||||
|
return file_parts
|
||||||
|
|
||||||
|
def prefix_order(
|
||||||
|
self,
|
||||||
|
scheme,
|
||||||
|
style,
|
||||||
|
strict=False,
|
||||||
|
) -> list[tuple[str, str]]:
|
||||||
|
'''
|
||||||
|
Determine the order of concrete config pathname parts to match, given the
|
||||||
|
``scheme`` and ``style`` inputs.
|
||||||
|
|
||||||
|
There is a unique preferred match order when ``style``, ``scheme``, both, or none
|
||||||
|
are ``any``. In general, when ``any`` is provided for a given factor, it is
|
||||||
|
best matched by a config file that expresses indifference under that factor.
|
||||||
|
'''
|
||||||
|
# explicit cases are the most easily managed here, even if a little redundant
|
||||||
|
if strict:
|
||||||
|
theme_order = [
|
||||||
|
(style, scheme),
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
# inverse order of match relaxation; intention being to overwrite with
|
||||||
|
# results from increasingly relevant groups given the conditions
|
||||||
|
if style == 'any' and scheme == 'any':
|
||||||
|
# prefer both be "none", with preference for specific scheme
|
||||||
|
theme_order = [
|
||||||
|
(style , scheme),
|
||||||
|
(style , 'none'),
|
||||||
|
('none' , scheme),
|
||||||
|
('none' , 'none'),
|
||||||
|
]
|
||||||
|
elif style == 'any':
|
||||||
|
# prefer style to be "none", then specific, then relax specific scheme
|
||||||
|
# to "none"
|
||||||
|
theme_order = [
|
||||||
|
(style , 'none'),
|
||||||
|
('none' , 'none'),
|
||||||
|
(style , scheme),
|
||||||
|
('none' , scheme),
|
||||||
|
]
|
||||||
|
elif scheme == 'any':
|
||||||
|
# prefer scheme to be "none", then specific, then relax specific style
|
||||||
|
# to "none"
|
||||||
|
theme_order = [
|
||||||
|
('none' , scheme),
|
||||||
|
('none' , 'none'),
|
||||||
|
(style , scheme),
|
||||||
|
(style , 'none'),
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
# neither component is any; prefer most specific
|
||||||
|
theme_order = [
|
||||||
|
('none' , 'none'),
|
||||||
|
('none' , scheme),
|
||||||
|
(style , 'none'),
|
||||||
|
(style , scheme),
|
||||||
|
]
|
||||||
|
|
||||||
|
return theme_order
|
||||||
|
|
||||||
|
def match_paths(
|
||||||
|
self,
|
||||||
|
paths: list[str | Path],
|
||||||
|
prefix_order: list[tuple[str, str]],
|
||||||
|
) -> list[FilePart]:
|
||||||
|
'''
|
||||||
|
Find and return FilePart matches according to the provided prefix order.
|
||||||
|
|
||||||
|
The prefix order specifies all valid style-scheme combos that can be considered as
|
||||||
|
"consistent" with some user input (and is computed external to this method). For
|
||||||
|
example, it could be
|
||||||
|
|
||||||
|
```py
|
||||||
|
[
|
||||||
|
('none', 'none')
|
||||||
|
('none', 'dark')
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
indicating that either ``none-none.<config>`` or ``none-dark.<config>`` would be
|
||||||
|
considered matching pathnames, with the latter being preferred.
|
||||||
|
|
||||||
|
This method exists because we need a way to allow any of the combos in the prefix
|
||||||
|
order to match the candidate files. We don't know a priori how good of a match
|
||||||
|
will be available, so we consider each file for each of the prefixes, and take the
|
||||||
|
latest/best match for each unique config pathname (allowing for a "soft" match).
|
||||||
|
|
||||||
|
.. admonition: Checking for matches
|
||||||
|
|
||||||
|
When thinking about how best to structure this method, it initially felt like
|
||||||
|
indexing factors of the FileParts would make the most sense, preventing the
|
||||||
|
inner loop that needs to inspect each FilePart for each element of the prefix
|
||||||
|
order. But indexing the file parts and checking against prefixes isn't so
|
||||||
|
straightforward, as we'd still need to check matches by factor. For instance,
|
||||||
|
if we index by style-scheme, either are allowed to be "any," so we'd need to
|
||||||
|
check for the 4 valid combos and join the matching lists. If we index by both
|
||||||
|
factors individually, we may have several files associated with a given key,
|
||||||
|
and then need to coordinate the checks across both to ensure they belong to
|
||||||
|
the same file.
|
||||||
|
|
||||||
|
In any case, you should be able to do this in a way that's a bit more
|
||||||
|
efficient, but the loop and the simple conditionals is just much simpler to
|
||||||
|
follow. We're also talking about at most 10s of files, so it really doesn't
|
||||||
|
matter.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
pathnames:
|
||||||
|
scheme:
|
||||||
|
style:
|
||||||
|
prefix_order:
|
||||||
|
strict:
|
||||||
|
'''
|
||||||
|
file_parts = self.get_file_parts(paths)
|
||||||
|
|
||||||
|
ordered_matches = []
|
||||||
|
for i, (style_prefix, scheme_prefix) in enumerate(prefix_order):
|
||||||
|
for fp in file_parts:
|
||||||
|
style_match = style_prefix == fp.style or style_prefix == 'any'
|
||||||
|
scheme_match = scheme_prefix == fp.scheme or scheme_prefix == 'any'
|
||||||
|
|
||||||
|
if style_match and scheme_match:
|
||||||
|
fp.set_index(i+1)
|
||||||
|
ordered_matches.append(fp)
|
||||||
|
|
||||||
|
return ordered_matches
|
||||||
|
|
||||||
|
def relaxed_match(
|
||||||
|
self,
|
||||||
|
match_list: list[FilePart]
|
||||||
|
) -> list[FilePart]:
|
||||||
|
'''
|
||||||
|
Isolate the best match in a match list and find its relaxed variants.
|
||||||
|
|
||||||
|
This method allows us to use the ``match_paths()`` method for matching templates
|
||||||
|
rather than direct user config files. In the latter case, we want to symlink the
|
||||||
|
single best config file match for each stem, across all stems with matching
|
||||||
|
prefixes (e.g., ``none-dark.config.a`` and ``solarized-dark.config.b`` have two
|
||||||
|
separate stems with prefixes that could match ``scheme=dark, style=any`` query).
|
||||||
|
We can find these files by just indexing the ``match_path`` outputs (i.e., all
|
||||||
|
matches) by config pathname and taking the one that appears latest (under the
|
||||||
|
prefix order) for each unique value.
|
||||||
|
|
||||||
|
In the template matching case, we want only a single best file match, period
|
||||||
|
(there's really no notion of "config stems," it's just the prefixes). Once that
|
||||||
|
match has been found, we can then "relax" either the scheme or style (or both) to
|
||||||
|
``none``, and if the corresponding files exist, we use those as parts of the
|
||||||
|
template keys. For example, if we match ``solarized-dark.toml``, we would also
|
||||||
|
consider the values in ``none-dark.toml`` if available. The TOML values that are
|
||||||
|
defined in the most specific (i.e., better under the prefix order) match are
|
||||||
|
loaded "on top of" those less specific matches, overwriting keys when there's a
|
||||||
|
conflict. ``none-dark.toml``, for instance, might define a general dark scheme
|
||||||
|
background color, but a more specific definition in ``solarized-dark.toml`` would
|
||||||
|
take precedent. These TOML files would be stacked before using the resulting
|
||||||
|
dictionary to populate config templates.
|
||||||
|
'''
|
||||||
|
if not match_list:
|
||||||
|
return []
|
||||||
|
|
||||||
|
relaxed_map = {}
|
||||||
|
match = match_list[-1]
|
||||||
|
|
||||||
|
for fp in match_list:
|
||||||
|
style_match = fp.style == match.style or fp.style == 'none'
|
||||||
|
scheme_match = fp.scheme == match.scheme or fp.scheme == 'none'
|
||||||
|
|
||||||
|
if style_match and scheme_match:
|
||||||
|
relaxed_map[fp.pathname] = fp
|
||||||
|
|
||||||
|
return list(relaxed_map.values())
|
||||||
|
|
61
symconf/runner.py
Normal file
61
symconf/runner.py
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from colorama import Fore, Back, Style
|
||||||
|
|
||||||
|
from symconf.util import printc, color_text
|
||||||
|
|
||||||
|
|
||||||
|
class Runner:
|
||||||
|
def run_script(
|
||||||
|
self,
|
||||||
|
script: str | Path,
|
||||||
|
):
|
||||||
|
script_path = Path(script)
|
||||||
|
|
||||||
|
if script_path.stat().st_mode & stat.S_IXUSR == 0:
|
||||||
|
print(
|
||||||
|
color_text("│", Fore.BLUE),
|
||||||
|
color_text(
|
||||||
|
f' > script "{script_path.name}" missing execute permissions, skipping',
|
||||||
|
Fore.RED + Style.DIM
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
print(
|
||||||
|
color_text("│", Fore.BLUE),
|
||||||
|
color_text(
|
||||||
|
f' > running script "{script_path.name}"',
|
||||||
|
Fore.BLUE
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
output = subprocess.check_output(str(script_path), shell=True)
|
||||||
|
|
||||||
|
if output:
|
||||||
|
fmt_output = output.decode().strip().replace(
|
||||||
|
'\n',
|
||||||
|
f'\n{Fore.BLUE}{Style.NORMAL}│{Style.DIM} '
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
color_text("│", Fore.BLUE),
|
||||||
|
color_text(
|
||||||
|
f' captured script output "{fmt_output}"',
|
||||||
|
Fore.BLUE + Style.DIM
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return output
|
||||||
|
|
||||||
|
def run_many(
|
||||||
|
self,
|
||||||
|
script_list: list[str | Path],
|
||||||
|
):
|
||||||
|
outputs = []
|
||||||
|
for script in script_list:
|
||||||
|
output = self.run_script(script)
|
||||||
|
outputs.append(output)
|
||||||
|
|
||||||
|
return outputs
|
70
symconf/template.py
Normal file
70
symconf/template.py
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import re
|
||||||
|
import tomllib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from symconf import util
|
||||||
|
from symconf.reader import DictReader
|
||||||
|
|
||||||
|
|
||||||
|
class Template:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
template_str : str,
|
||||||
|
pattern : str = r'f{{(\S+?)}}',
|
||||||
|
):
|
||||||
|
self.template_str = template_str
|
||||||
|
self.pattern = pattern
|
||||||
|
|
||||||
|
def fill(
|
||||||
|
self,
|
||||||
|
template_dict : dict,
|
||||||
|
) -> str:
|
||||||
|
dr = DictReader.from_dict(template_dict)
|
||||||
|
|
||||||
|
return re.sub(
|
||||||
|
self.pattern,
|
||||||
|
lambda m: str(dr.get(m.group(1))),
|
||||||
|
self.template_str
|
||||||
|
)
|
||||||
|
|
||||||
|
class FileTemplate(Template):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
path : Path,
|
||||||
|
pattern : str = r'f{{(\S+)}}',
|
||||||
|
):
|
||||||
|
super().__init__(
|
||||||
|
path.open('r').read(),
|
||||||
|
pattern=pattern
|
||||||
|
)
|
||||||
|
|
||||||
|
class TOMLTemplate(FileTemplate):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
toml_path : Path,
|
||||||
|
pattern : str = r'f{{(\S+)}}',
|
||||||
|
):
|
||||||
|
super().__init__(
|
||||||
|
toml_path,
|
||||||
|
pattern=pattern
|
||||||
|
)
|
||||||
|
|
||||||
|
def fill(
|
||||||
|
self,
|
||||||
|
template_dict : dict,
|
||||||
|
) -> str:
|
||||||
|
filled_template = super().fill(template_dict)
|
||||||
|
toml_dict = tomllib.loads(filled_template)
|
||||||
|
|
||||||
|
return toml_dict
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def stack_toml(
|
||||||
|
path_list: list[Path]
|
||||||
|
) -> dict:
|
||||||
|
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
|
@ -1,7 +1,46 @@
|
|||||||
|
import re
|
||||||
import argparse
|
import argparse
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from xdg import BaseDirectory
|
|
||||||
|
|
||||||
|
from xdg import BaseDirectory
|
||||||
|
from colorama import Fore, Back, Style
|
||||||
|
from colorama.ansi import AnsiFore, AnsiBack, AnsiStyle
|
||||||
|
|
||||||
|
|
||||||
|
def color_text(text, *colorama_args):
|
||||||
|
'''
|
||||||
|
Colorama text helper function
|
||||||
|
|
||||||
|
Note: we attempt to preserve expected nested behavior by only resetting the groups
|
||||||
|
(Fore, Back, Style) affected the styles passed in. This works when an outer call is
|
||||||
|
changing styles in one group, and an inner call is changing styles in another, but
|
||||||
|
_not_ when affected groups overlap.
|
||||||
|
|
||||||
|
For example, if an outer call is setting the foreground color (e.g., ``Fore.GREEN``),
|
||||||
|
nested calls on the text being passed into the function can modify and reset the
|
||||||
|
background or style with affecting the foreground. The primary use case here is
|
||||||
|
styling a group of text a single color, but applying ``BRIGHT`` or ``DIM`` styles only
|
||||||
|
to some text elements within. If we didn't reset by group, the outer coloration
|
||||||
|
request will be "canceled out" as soon as the first inner call is made (since the
|
||||||
|
unconditional behavior just employs ``Style.RESET_ALL``).
|
||||||
|
'''
|
||||||
|
# reverse map colorama Ansi codes
|
||||||
|
resets = []
|
||||||
|
for carg in colorama_args:
|
||||||
|
match = re.match(r'.*\[(\d+)m', carg)
|
||||||
|
if match:
|
||||||
|
intv = int(match.group(1))
|
||||||
|
if (intv >= 30 and intv <= 39) or (intv >= 90 and intv <= 97):
|
||||||
|
resets.append(Fore.RESET)
|
||||||
|
elif (intv >= 40 and intv <= 49) or (intv >= 100 and intv <= 107):
|
||||||
|
resets.append(Back.RESET)
|
||||||
|
elif (intv >= 0 and intv <= 2) or intv == 22:
|
||||||
|
resets.append(Style.NORMAL)
|
||||||
|
|
||||||
|
return f"{''.join(colorama_args)}{text}{''.join(resets)}"
|
||||||
|
|
||||||
|
def printc(text, *colorama_args):
|
||||||
|
print(color_text(text, *colorama_args))
|
||||||
|
|
||||||
def absolute_path(path: str | Path) -> Path:
|
def absolute_path(path: str | Path) -> Path:
|
||||||
return Path(path).expanduser().absolute()
|
return Path(path).expanduser().absolute()
|
||||||
@ -9,6 +48,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 to_tilde_path(path: Path) -> Path:
|
||||||
|
'''
|
||||||
|
Abbreviate an absolute path by replacing HOME with "~", if applicable.
|
||||||
|
'''
|
||||||
|
try:
|
||||||
|
return Path(f"~/{path.relative_to(Path.home())}")
|
||||||
|
except ValueError:
|
||||||
|
return path
|
||||||
|
|
||||||
def deep_update(mapping: dict, *updating_mappings: dict) -> dict:
|
def deep_update(mapping: dict, *updating_mappings: dict) -> dict:
|
||||||
'''Code adapted from pydantic'''
|
'''Code adapted from pydantic'''
|
||||||
updated_mapping = mapping.copy()
|
updated_mapping = mapping.copy()
|
||||||
|
@ -0,0 +1,2 @@
|
|||||||
|
[app.test]
|
||||||
|
config_dir = 'sym_tgt/test'
|
@ -1 +0,0 @@
|
|||||||
gsettings set org.gnome.desktop.interface color-scheme 'prefer-dark'
|
|
@ -1 +0,0 @@
|
|||||||
gsettings set org.gnome.desktop.interface color-scheme 'prefer-light'
|
|
@ -1 +1 @@
|
|||||||
echo "> none-light ran"
|
echo "none-light ran"
|
||||||
|
@ -1 +1 @@
|
|||||||
echo "> none-none ran"
|
echo "none-none ran"
|
||||||
|
1
tests/test-config-dir/groups/test/none.toml
Normal file
1
tests/test-config-dir/groups/test/none.toml
Normal file
@ -0,0 +1 @@
|
|||||||
|
base = "aaa"
|
1
tests/test-config-dir/groups/test/test.toml
Normal file
1
tests/test-config-dir/groups/test/test.toml
Normal file
@ -0,0 +1 @@
|
|||||||
|
concrete = "zzz"
|
@ -1,132 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
from symconf import ConfigManager
|
|
||||||
|
|
||||||
|
|
||||||
config_dir = Path(
|
|
||||||
__file__, '..', 'test-config-dir/'
|
|
||||||
).resolve()
|
|
||||||
cm = ConfigManager(config_dir)
|
|
||||||
|
|
||||||
def test_config_map():
|
|
||||||
file_map = cm.app_config_map('test')
|
|
||||||
|
|
||||||
# from user
|
|
||||||
assert 'none-none.aaa' in file_map
|
|
||||||
assert 'none-light.aaa' in file_map
|
|
||||||
assert 'test-dark.bbb' in file_map
|
|
||||||
assert 'test-light.ccc' in file_map
|
|
||||||
|
|
||||||
# from generated
|
|
||||||
assert 'test-none.aaa' in file_map
|
|
||||||
|
|
||||||
def test_matching_configs_exact():
|
|
||||||
'''
|
|
||||||
Test matching exact palette and scheme. Given strict mode not set (allowing relaxation
|
|
||||||
to "none"), the order of matching should be
|
|
||||||
|
|
||||||
1. (none, none) :: none-none.aaa
|
|
||||||
2. (none, scheme) :: none-light.aaa
|
|
||||||
3. (palette, none) :: test-none.aaa & test-none.ddd
|
|
||||||
4. (palette, scheme) :: test-light.ccc
|
|
||||||
|
|
||||||
Yielding "test-none.aaa", "test-light.ccc", "test-none.ddd" (unique only on path name).
|
|
||||||
'''
|
|
||||||
any_light = cm.get_matching_configs(
|
|
||||||
'test',
|
|
||||||
palette='test',
|
|
||||||
scheme='light',
|
|
||||||
)
|
|
||||||
|
|
||||||
assert len(any_light) == 3
|
|
||||||
assert any_light['aaa'].name == 'test-none.aaa'
|
|
||||||
assert any_light['ccc'].name == 'test-light.ccc'
|
|
||||||
assert any_light['ddd'].name == 'test-none.ddd'
|
|
||||||
|
|
||||||
def test_matching_configs_any_palette():
|
|
||||||
'''
|
|
||||||
Test matching exact palette and scheme. Given strict mode not set (allowing relaxation
|
|
||||||
to "none"), the order of matching should be
|
|
||||||
|
|
||||||
1. (palette, none) :: test-none.aaa & test-none.ddd & none-none.aaa
|
|
||||||
2. (none, none) :: none-none.aaa
|
|
||||||
3. (palette, scheme) :: test-dark.bbb
|
|
||||||
4. (none, scheme) :: (nothing)
|
|
||||||
|
|
||||||
Yielding "none-none.aaa" (should always overwrite "test-none.aaa" due to "any"'s
|
|
||||||
preference for non-specific matches, i.e., "none"s), "test-none.ddd", "test-dark.bbb"
|
|
||||||
(unique only on path name).
|
|
||||||
'''
|
|
||||||
any_dark = cm.get_matching_configs(
|
|
||||||
'test',
|
|
||||||
palette='any',
|
|
||||||
scheme='dark',
|
|
||||||
)
|
|
||||||
|
|
||||||
assert len(any_dark) == 3
|
|
||||||
assert any_dark['aaa'].name == 'none-none.aaa'
|
|
||||||
assert any_dark['bbb'].name == 'test-dark.bbb'
|
|
||||||
assert any_dark['ddd'].name == 'test-none.ddd'
|
|
||||||
|
|
||||||
def test_matching_configs_any_scheme():
|
|
||||||
'''
|
|
||||||
Test matching exact palette and scheme. Given strict mode not set (allowing relaxation
|
|
||||||
to "none"), the order of matching should be
|
|
||||||
|
|
||||||
1. (none, scheme) :: none-light.aaa & none-none.aaa
|
|
||||||
2. (none, none) :: none-none.aaa
|
|
||||||
3. (palette, scheme) :: test-dark.bbb & test-light.ccc & test-none.aaa & test-none.ddd
|
|
||||||
4. (palette, none) :: test-none.aaa & test-none.ddd
|
|
||||||
|
|
||||||
Yielding "test-none.aaa", "test-none.ddd", "test-light.ccc", "test-dark.bbb"
|
|
||||||
'''
|
|
||||||
test_any = cm.get_matching_configs(
|
|
||||||
'test',
|
|
||||||
palette='test',
|
|
||||||
scheme='any',
|
|
||||||
)
|
|
||||||
|
|
||||||
assert len(test_any) == 4
|
|
||||||
assert test_any['aaa'].name == 'test-none.aaa'
|
|
||||||
assert test_any['bbb'].name == 'test-dark.bbb'
|
|
||||||
assert test_any['ccc'].name == 'test-light.ccc'
|
|
||||||
assert test_any['ddd'].name == 'test-none.ddd'
|
|
||||||
|
|
||||||
def test_matching_scripts():
|
|
||||||
'''
|
|
||||||
Test matching exact palette and scheme. Given strict mode not set (allowing relaxation
|
|
||||||
to "none"), the order of matching should be
|
|
||||||
|
|
||||||
1. (none, none) :: none-none.sh
|
|
||||||
2. (none, scheme) :: none-light.sh
|
|
||||||
3. (palette, none) :: test-none.sh
|
|
||||||
4. (palette, scheme) :: (nothing)
|
|
||||||
|
|
||||||
Yielding (ordered by dec specificity) "test-none.sh" as primary match, then relaxation
|
|
||||||
match "none-none.sh".
|
|
||||||
'''
|
|
||||||
test_any = cm.get_matching_scripts(
|
|
||||||
'test',
|
|
||||||
palette='test',
|
|
||||||
scheme='any',
|
|
||||||
)
|
|
||||||
|
|
||||||
assert len(test_any) == 2
|
|
||||||
assert list(map(lambda p:p.name, test_any)) == ['test-none.sh', 'none-none.sh']
|
|
||||||
|
|
||||||
any_light = cm.get_matching_scripts(
|
|
||||||
'test',
|
|
||||||
palette='any',
|
|
||||||
scheme='light',
|
|
||||||
)
|
|
||||||
|
|
||||||
assert len(any_light) == 2
|
|
||||||
assert list(map(lambda p:p.name, any_light)) == ['none-light.sh', 'none-none.sh']
|
|
||||||
|
|
||||||
any_dark = cm.get_matching_scripts(
|
|
||||||
'test',
|
|
||||||
palette='any',
|
|
||||||
scheme='dark',
|
|
||||||
)
|
|
||||||
|
|
||||||
assert len(any_dark) == 2
|
|
||||||
assert list(map(lambda p:p.name, any_dark)) == ['test-none.sh', 'none-none.sh']
|
|
@ -1,5 +1,11 @@
|
|||||||
def test_imports():
|
def test_imports():
|
||||||
from symconf import ConfigManager
|
from symconf.runner import Runner
|
||||||
|
from symconf.reader import DictReader
|
||||||
|
from symconf.config import ConfigManager
|
||||||
|
from symconf.matching import Matcher, FilePart
|
||||||
|
from symconf.template import Template, FileTemplate, TOMLTemplate
|
||||||
|
|
||||||
from symconf import config
|
from symconf import config
|
||||||
|
from symconf import reader
|
||||||
from symconf import theme
|
from symconf import theme
|
||||||
|
from symconf import util
|
||||||
|
120
tests/test_matching.py
Normal file
120
tests/test_matching.py
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from symconf import ConfigManager
|
||||||
|
|
||||||
|
|
||||||
|
config_dir = Path(
|
||||||
|
__file__, '..', 'test-config-dir/'
|
||||||
|
).resolve()
|
||||||
|
cm = ConfigManager(config_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def test_matching_configs_exact():
|
||||||
|
'''
|
||||||
|
Test matching exact style and scheme. Given strict mode not set (allowing relaxation
|
||||||
|
to "none"), the order of matching should be
|
||||||
|
|
||||||
|
1. (none, none) :: none-none.aaa
|
||||||
|
2. (none, scheme) :: none-light.aaa
|
||||||
|
3. (style, none) :: test-none.aaa
|
||||||
|
4. (style, scheme) :: test-light.ccc
|
||||||
|
|
||||||
|
Yielding "test-light.aaa", "test-light.ccc" (unique only on config pathname).
|
||||||
|
'''
|
||||||
|
any_light = cm.get_matching_configs(
|
||||||
|
'test',
|
||||||
|
style='test',
|
||||||
|
scheme='light',
|
||||||
|
)
|
||||||
|
print(any_light)
|
||||||
|
|
||||||
|
assert len(any_light) == 2
|
||||||
|
assert any_light['aaa'].pathname == 'test-none.aaa'
|
||||||
|
assert any_light['ccc'].pathname == 'test-light.ccc'
|
||||||
|
|
||||||
|
def test_matching_configs_any_style():
|
||||||
|
'''
|
||||||
|
Test matching "any" style and exact scheme. Given strict mode not set (allowing
|
||||||
|
relaxation to "none"), the order of matching should be
|
||||||
|
|
||||||
|
1. (style, none) :: none-none.aaa, test-none.aaa
|
||||||
|
2. (none, none) :: none-none.aaa
|
||||||
|
3. (style, scheme) :: test-dark.bbb
|
||||||
|
4. (none, scheme) :: (nothing)
|
||||||
|
|
||||||
|
Yielding "none-none.aaa" (should always overwrite "test-none.aaa" due to "any"'s
|
||||||
|
preference for non-specific matches, i.e., "none"s), "test-none.ddd", "test-dark.bbb"
|
||||||
|
(unique only on config pathname).
|
||||||
|
'''
|
||||||
|
any_dark = cm.get_matching_configs(
|
||||||
|
'test',
|
||||||
|
style='any',
|
||||||
|
scheme='dark',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(any_dark) == 2
|
||||||
|
assert any_dark['aaa'].pathname == 'none-none.aaa'
|
||||||
|
assert any_dark['bbb'].pathname == 'test-dark.bbb'
|
||||||
|
|
||||||
|
def test_matching_configs_any_scheme():
|
||||||
|
'''
|
||||||
|
Test matching exact style and "any" scheme. Given strict mode not set (allowing
|
||||||
|
relaxation to "none"), the order of matching should be
|
||||||
|
|
||||||
|
1. (none, scheme) :: none-light.aaa & none-none.aaa
|
||||||
|
2. (none, none) :: none-none.aaa
|
||||||
|
3. (style, scheme) :: test-dark.bbb & test-light.ccc & test-none.aaa
|
||||||
|
4. (style, none) :: test-none.aaa
|
||||||
|
|
||||||
|
Yielding "test-none.aaa", "test-light.ccc", "test-dark.bbb"
|
||||||
|
'''
|
||||||
|
test_any = cm.get_matching_configs(
|
||||||
|
'test',
|
||||||
|
style='test',
|
||||||
|
scheme='any',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(test_any) == 3
|
||||||
|
assert test_any['aaa'].pathname == 'test-none.aaa'
|
||||||
|
assert test_any['bbb'].pathname == 'test-dark.bbb'
|
||||||
|
assert test_any['ccc'].pathname == 'test-light.ccc'
|
||||||
|
|
||||||
|
def test_matching_scripts():
|
||||||
|
'''
|
||||||
|
Test matching exact style and scheme. Given strict mode not set (allowing relaxation
|
||||||
|
to "none"), the order of matching should be
|
||||||
|
|
||||||
|
1. (none, none) :: none-none.sh
|
||||||
|
2. (none, scheme) :: none-light.sh
|
||||||
|
3. (style, none) :: test-none.sh
|
||||||
|
4. (style, scheme) :: (nothing)
|
||||||
|
|
||||||
|
Yielding (ordered by dec specificity) "test-none.sh" as primary match, then relaxation
|
||||||
|
match "none-none.sh".
|
||||||
|
'''
|
||||||
|
test_any = cm.get_matching_scripts(
|
||||||
|
'test',
|
||||||
|
style='test',
|
||||||
|
scheme='any',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(test_any) == 2
|
||||||
|
assert list(map(lambda p:p.pathname, test_any)) == ['test-none.sh', 'none-none.sh']
|
||||||
|
|
||||||
|
any_light = cm.get_matching_scripts(
|
||||||
|
'test',
|
||||||
|
style='any',
|
||||||
|
scheme='light',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(any_light) == 2
|
||||||
|
assert list(map(lambda p:p.pathname, any_light)) == ['none-light.sh', 'none-none.sh']
|
||||||
|
|
||||||
|
any_dark = cm.get_matching_scripts(
|
||||||
|
'test',
|
||||||
|
style='any',
|
||||||
|
scheme='dark',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(any_dark) == 2
|
||||||
|
assert list(map(lambda p:p.pathname, any_dark)) == ['test-none.sh', 'none-none.sh']
|
31
tests/test_template.py
Normal file
31
tests/test_template.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from symconf import Template, TOMLTemplate
|
||||||
|
|
||||||
|
def test_template_fill():
|
||||||
|
# test simple replacment
|
||||||
|
assert Template('f{{a}} - f{{b}}').fill({
|
||||||
|
'a': 1,
|
||||||
|
'b': 2,
|
||||||
|
}) == '1 - 2'
|
||||||
|
|
||||||
|
# test nested brackets (using default pattern)
|
||||||
|
assert Template('{{ f{{a}} - f{{b}} }}').fill({
|
||||||
|
'a': 1,
|
||||||
|
'b': 2,
|
||||||
|
}) == '{{ 1 - 2 }}'
|
||||||
|
|
||||||
|
# test tight nested brackets (requires greedy quantifier)
|
||||||
|
assert Template('{{f{{a}} - f{{b}}}}').fill({
|
||||||
|
'a': 1,
|
||||||
|
'b': 2,
|
||||||
|
}) == '{{1 - 2}}'
|
||||||
|
|
||||||
|
def test_toml_template_fill():
|
||||||
|
test_group_dir = Path(
|
||||||
|
__file__, '..', 'test-config-dir/groups/test/'
|
||||||
|
).resolve()
|
||||||
|
|
||||||
|
stacked_dict = TOMLTemplate.stack_toml(test_group_dir.iterdir())
|
||||||
|
|
||||||
|
assert stacked_dict == {'base':'aaa','concrete':'zzz'}
|
Loading…
Reference in New Issue
Block a user