Compare commits

..

23 Commits

Author SHA1 Message Date
df5262ec05 revise sphinx doc build config 2025-10-11 23:03:46 -07:00
13e366d1fe add minor details to usage docs 2025-09-28 03:43:59 -07:00
4d707b97e4 improve type hint coverage, make ruff format compliant 2025-09-28 00:31:32 -07:00
2b0702fe36 implement control flow for attaining custom user permissions 2025-09-26 04:33:19 -07:00
96fba06709 fix bug in template exec pattern 2024-08-18 14:14:51 -07:00
2553bc14af add py execution support to templates, loosen generate matches 2024-08-18 11:56:28 -07:00
017f1c5b1c update README links, minor logging fix 2024-08-12 01:52:28 -07:00
433af79028 add template generation functionality, version mapping 2024-08-12 00:23:39 -07:00
b565d99882 fix CLI arg clash 2024-08-11 16:01:18 -07:00
c0d94b2de7 fix README tag issue 2024-08-11 05:18:47 -07:00
4ab6c4f100 modify argument names, update README with examples and demo 2024-08-11 04:13:39 -07:00
e2f1fd30b6 minor README update 2024-08-11 01:19:13 -07:00
bf311d57a5 large refactor (break up ConfigManager), add more tests 2024-08-10 23:48:35 -07:00
cb1dd52833 add install/update subcommands, add key-val spec for templates 2024-07-12 03:01:13 -07:00
8a78da1a28 fix minor script match bug 2024-07-09 15:36:58 -07:00
ec5893581e fix small config-map bug 2024-07-09 15:34:19 -07:00
8afdadc263 fix bug when group dir doesn't exist 2024-07-09 14:52:42 -07:00
5bb280b1da add binary target in pyproject 2024-07-09 12:18:16 -07:00
533c533034 add support for palette variants, minor bug fixes 2024-07-07 16:30:07 -07:00
3120638ef3 add template generation, generalize search heuristics 2024-07-07 04:21:51 -07:00
2f78fa0527 clean up for refined release 2024-07-05 04:15:06 -07:00
b72de8e28f improve output formatting 2024-07-05 04:11:54 -07:00
fd32cfd5ba overhaul ConfigManager, add basic tests, add basic docs 2024-07-05 02:06:05 -07:00
51 changed files with 3517 additions and 824 deletions

2
.gitignore vendored
View File

@ -5,6 +5,8 @@ __pycache__/
.ipynb_checkpoints/
.pytest_cache/
.python-version
.venv/
.ruff_cache/
# vendor and build files
dist/

348
README.md
View File

@ -1,233 +1,157 @@
# autoconf
The `autoconf` project is an attempt at wrangling the complexity of configuring many
applications across one's Linux system. It provides a simple operational model for pulling
many application config files into one place, as well as generating/setting color schemes
across apps.
# Symconf
`symconf` is a CLI tool for managing local application configuration. It
implements a general model that supports dynamically switching/reloading themes
for any application, and provides a basic means of templatizing your config
files.
Quick terminology rundown for theme-related items:
## Simple example
Below is a simple example demonstrating two system-wide theme switches:
- **Theme**: loose term referring generally to the overall aesthetic of a visual setting.
Ignoring stylistic changes (only applicable to some apps; example here might be a
a particular setting of the `waybar` layout), a theme is often just the wrapper term for
a choice of color _palette_ and _scheme_. For example, "tone4-light" could be a _theme_
setting for an app like `kitty`, referring to both a palette and scheme.
- **Palette**: a set of base colors used to style text or other aspects of an app's
displayed assets
- **Scheme**: an indication of lightness, typically either "light" or "dark.
![Simple example](docs/_static/example.gif)
As far as managing settings across apps, there are current two useful classifications
here:
This GIF shows two `symconf` calls, the first of which applies a `gruvbox` dark
theme and the second a dark [`monobiome`][1] variant. Each call (of the form
`symconf config -m dark -s style`) indicates a dark mode preference and a
particular color palette that should be used when populating config file
templates. Specifically, in this example, invoking `symconf` results in the
following app-specific config updates:
1. **Inseparable from theme**: some apps (e.g., `sway`, `waybar`) have color scheme
components effectively built in to their canonical configuration file. This can make it
hard to set themes dynamically, as it would likely require some involved
matching/substitution rules. This is not a level of complexity I'm willing to embrace,
so we simply split the config files according to theme and/or scheme.
2. **Can load an external theme file**: some apps (e.g., `kitty`) have a clear mechanism
for loading themes. This typically implies some distinct color format, although usually
somewhat easy to generate (don't have to navigate non-color settings, for instance).
Such apps allow for an even less "invasive" config swapping process when setting a new
theme, as one can just swap out the external theme file.
- **GTK**: reacts to the mode setting and sets `prefer-dark` system-wide,
changing general GTK-responsive applications like Nautilus and Firefox (and
subsequently websites that are responsive to `prefers-color-scheme`)
- **kitty**: theme template is re-generated using the specified palette, and
`kitty` processes are sent a message to live-reload the new config file
- **neovim**: a `vim` theme file (along with a statusline theme) is generated
from the chosen palette, and running instances of `neovim` are sent a message
to re-source this theme (via `nvim --remote-send`)
- **waybar**: bar styles are updated to match the mode setting
- **sway**: the background color and window borders are dynamically set to base
palette colors, and `swaymsg reload` is called
- **fzf**: a palette-dependent theme is re-generated and re-exported
- **rofi**: launcher text and highlight colors are set according to the mode
and palette, applying on next invocation
To be clear on operation implications here: apps of type (1) must have _manually
maintained_ config variations according the desired themes. General theme settings must
follow the naming scheme `<app-name>-<palette>-<scheme>.<ext>`. For example, if I wanted to set
`sway` to a light variation (which, at the time of writing, would just entail changing a
single background color), I must have explicitly created a `sway-tone4-light.conf` file
that captures this setting. The canonical config file will then be symlinked to the
theme-specific file when the theme is set. (Note that the palette in this example is pretty much
irrelevant, but it needs to be present in order to match the overarching setting; here you
can just think of the format being `<app-name>-<theme>.<ext>`, where `tone4-light` is the
provided theme name.)
This example highlights the generality of `symconf`, and so long as an app's
config can be reloaded dynamically, you can use a single `symconf` call to
apply themes for an arbitrary number of apps at once.
For apps of type (2), the canonical config file can remain untouched so long as it refers
to a fixed, generic theme file. For example, with `kitty`, my config file can point to a
`current-theme.conf` file, which will be symlinked to a specific theme file here in
`autoconf` when a change is requested. This enables a couple of conveniences:
# Behavior
`symconf` uses a simple operational model that symlinks centralized config
files to their expected locations across the system. This central config
directory can then be version controlled, and app config files can be updated
in one place.
- The true config directory on disk remains unpolluted with theme variants.
- If the set theme is regenerated, there is no intervention necessary to propagate its
changes to the target app. The symlinked file itself will be updated when the theme
does, ensuring the latest theme version is always immediately available and pointed to
by the app.
App config files can either be concrete (fully-specified) or templates (to be
populated by values conditional on style, e.g., a palette). When `symconf` is
executed with a particular mode preference (dark or light) and a style (any
other indicator of thematic elements, often simply in the form of a palette
like `solarized` or `gruvbox`), it searches for both concrete and template
config files that match and symlinks them to registered locations. When
necessary, `symconf` will also match and execute scripts to reload apps after
updating their configuration.
Keep in mind that some apps may fall into some grey area here, allowing some external
customization but locking down other settings internally. In such instances, there's no
need to overcomplicate things; just stick to explicit config variants under the type (1)
umbrella. Type (2) only works for generated themes anyhow; even if the target app can load
an external theme, type (1) should be used if preset themes are fixed.
You can find more details on how `symconf`'s matching scheme works in
[Matching](docs/reference/matching.md).
## Naming standards
To keep things simple, we use a few fixed naming standards for setting app config files
and their themed counterparts. The app registry requires each theme-eligible app to
provide a config directory (`config_dir`), containing some canonical config file
(`config_file`) and to serve as a place for theme-specific config variations. The
following naming schemes must be used in order for theme switching to behave
appropriately:
# Configuring
Before using, you must first set up your config directory to house your config
files and give `symconf` something to act on. See
[Configuring](docs/reference/configuring.md) for details.
- When setting a theme for a particular app, the following variables will be available:
* `<app-name>`
* `<palette>`
* `<scheme>`
- For apps with `external_theme = False`, config variants must named as
`<app-name>-<palette>-<scheme>.<ext>`, where `<ext>` is the app's default config file
extension.
- For apps with `external_theme = True`, the file `<config-dir>/current-theme.conf` will
be used when symlinking the requested theme. The config file thus must point to this
file in order to change with the set theme.
# Installation
The recommended way to install `symconf` is via `pipx`, which is particularly
well-suited for managing Python packages meant to be used as CLI programs. With
`uv` on your system, you can install with
Additionally, the theme symlink will be created from the file
```sh
uv tool install symconf
```
```
<autoconf-root>/autoconf/themes/<palette>/apps/<app-name>/generated/<scheme>.conf
Alternatively, you can use `pipx` to similar effect:
```sh
pipx install symconf
```
You can also install via `pip`, or clone and install locally.
# Usage
- `-h --help`: print help message
- `-c --config-dir`: set the location of the `symconf` config directory
- `symconf config` is the subcommand used to match and set available config
files for registered applications
* `-a --apps`: comma-separate list of registered apps, or `"*"` (default) to
consider all registered apps.
* `-m --mode`: preferred lightness mode/scheme, either `light`, `dark`,
`any`, or `none`.
* `-s --style`: style indicator, often the name of a color palette, capturing
thematic details in a config file to be matched. `any` or `none` are
reserved keywords (see below).
* `-T --template-vars`: additional groups to use when populating templates,
in the form `<group>=<value>`, where `<group>` is a template group with a
folder `$CONFIG_HOME/groups/<group>/` and `<value>` should correspond to a
TOML file in this folder (i.e., `<value>.toml`).
- `symconf generate` is a subcommand that can be used for batch generation of
config files. It accepts the same arguments as `symconf config`, but rather
than selecting the best match to be used for the system setting, all matching
templates are generated. There is one additional required argument:
* `-o --output-dir`: the directory under which generated config files should
be written. App-specific subdirectories are created to house config files
for each provided app.
- `symconf install`: runs install scripts for matching apps that specify one
* `-a --apps`: comma-separate list of registered apps, or `"*"` (default) to
consider all registered apps.
- `symconf update`: runs update scripts for matching apps that specify one
* `-a --apps`: comma-separate list of registered apps, or `"*"` (default) to
consider all registered apps.
The keywords `any` and `none` can be used when specifying `--mode`, `--style`,
or as a value in `--template-vars` (and we refer to each of these variables as
_factors_ that help determine a config match):
- `any` will match config files with _any_ value for this factor, preferring
config files with a value `none`, indicating no dependence on the factor.
This is the default value when a factor is left unspecified.
- `none` will match `"none"` directly for a given factor (so no special
behavior), but used to indicate that a config file is independent of the
factor. For instance,
```sh
symconf config -m light -s none
```
to `<config-dir>/current-theme.conf`.
will match config files that capture the notion of a light mode, but do not
depend on or provide further thematic components such as a color palette.
## Directory structure
## Examples
- Set a dark mode for all registered apps, matching any available style/palette
component:
- `autoconf/`: main repo directory
* `config/`: app-specific configuration files. Each folder inside this directory is
app-specific, and the target of associated copy operations when a config sync is
performed. Nothing in this directory should pertain to any repo functionality; it
should only contain config files that originated elsewhere on the system.
* `themes/`: app-independent theme data files. Each folder in this directory should
correspond to a specific color palette and house any relevant color spec files
(currently likely be a `colors.json`). Also servers the output location for
generated theme files
* `<palette>/colors.json`: JSON formatted color key-value pairings for palette
colors. There's no standard here aside from the filename and format; downstream
app-specific TOML templates can be dependent on any key naming scheme within the
JSON.
+ `<palette>/apps/<app-name>/templates/`: houses the TOML maps for the color
palette `<palette>` under app `<app-name>`. Files `<fname>.toml` will be mapped to
`<fname>.conf` in the theme output folder (below), so ensure the naming
standards align with those outlined above.
+ `<palette>/apps/<app-name>/generated/`: output directory for generated scheme
variants. These are the symlink targets for dynamically set external themes.
* `app_registry.toml`: global application "registry" used by sync and theme-setting
scripts. This lets apps be dynamically added or removed from being eligible for
config-related operations.
```sh
symconf config -m dark
```
- Set `solarized` theme for `kitty` and match any available mode (light or
dark):
## Scripts
```sh
symconf config -s solarized -a kitty
```
- Set a dark `gruvbox` theme for multiple apps (but not all):
`set_theme.py`: sets a theme across select apps.
```sh
symconf config -m dark -s gruvbox -apps="kitty,nvim"
```
- Set a dark `gruvbox` theme for all apps, and attempt to match other template
elements:
- Applies to specific app with `-a <app>` , or to all apps in the `app_registry.toml` with
`-a "*"`.
- Uses symlinks to set canonical config files to theme-based variations. Which files get
set depends on the _app type_ (see above), which really just boils down to whether
theming (1) can be specified with an external format, and (2) if it depends on
auto-generated theme files from within `autoconf`.
- Palette and scheme are specified as expected. They are used to infer proper paths
according to naming and structure standards.
- Real config files will never be overwritten. To begin setting themes with the script,
you must delete the canonical config file expected by the app (and specified in the app
registry) to allow the first symlink to be set. From there on out, symlinks will be
automatically flushed.
- A report will be provided on which apps were successfully set to the requested theme,
along with the file stems. A number of checks are in place for the existence of involved
files and directories. Overall, the risk of overwritting a real config file is low; we
only flush existing symlinks, and if the would-be target for the requested theme (be it
from an auto-generated theme file, or from a manually manage config variant) doesn't
exist, that app's config will be completed skipped. Essentially, everything must be in
perfect shape before the symlink trigger is officially pulled.
```sh
symconf config -m dark -s gruvbox -T font=mono window=sharp
```
which would attempt to find and load key-value pairs in the files
`$CONFIG_HOME/groups/font/mono.toml` and
`$CONFIG_HOME/groups/window/sharp.toml` to be used as values when filling
templatized config files.
`gen_theme.py`: generates theme files for palettes by mapping their color definitions
through app-specific templates. These templates specific how to relate an app's theme
variables to the color names provided by the template.
- An app and palette are the two required parameters. If no template or output paths are
provided, they will be inferred according to the theme path standards seen above.
- The `--template` argument can be a directory or a file, depending on what theme files
you'd like to render.
- The `--output` path, if specified, must be a directory. Generated theme files take on
a name with the same stem as their source template, but using the `.conf` extension.
- The TOML templates should make config variable names to JSON dot-notation accessors. If
color definitions are nested, the dot notation should be properly expanded by the script
when mapping the colors to keyword values.
- There are a number of checks for existing paths, even those inferred (e.g., template and
output) from the palette and app. If the appropriate setup hasn't been followed, the
script will fail. Make sure the `theme` folder in question and it's nested `app`
directory are correctly setup before running the script. (Perhaps down the line there
are some easy auto-setup steps to take here, but I'm not making that jump now.)
- TODO: open up different app "writers," or make it easy to extend output syntax based on
the app in question. This would like be as simple as mapping app names to
line-generating functions, which accept the keyword and color (among other items). This
can be fleshed out as needed.
`sync.sh`: copies relevant configuration files from local paths into the `autoconf`
subpath. Markdown files in the docs directory then reference the local copies of these
files, meaning the documentation updates dynamically when the configuration files do. That
is, the (possibly extracted) config snippets will change with the current state of my
system config without any manual intervention of the documentation files.
### Specific theme-setting example
To make clear how the theme setting script works on my system, the following breaks down
exactly what steps are taken to exert as much scheme control as possible. Everything at this
point is wrapped up in a single `make set-<palette>-<scheme>` call; suppose we're
currently running the dark scheme (see first image) and I run `make set-tone4-light`:
![
Starting point; have a GTK app (GNOME files), `kitty`, and Firefox (with the
system-dependent default theme set). In Firefox, I have open `localsys` with its
scheme-mode to set to "auto," which should reflect the theme setting picked up by the
browser (and note the white tab icon).
](_static/set-theme-1.png)
_(Starting point; have a GTK app (GNOME files), `kitty`, and Firefox (with the
system-dependent default theme set). In Firefox, I have open `localsys` with its
scheme-mode to set to "auto," which should reflect the theme setting picked up by the
browser (and note the white tab icon).)_
1. `set_theme.py` is invoked. Global settings are applied first, based on my OS (`Linux`),
which calls
```
gsettings set org.gnome.desktop.interface color-scheme 'prefer-light'
```
controlling settings for GTK apps and other `desktop-portal`-aware programs. This
yields the following:
![Portal-aware apps changed, config apps not yet set](_static/set-theme-2.png)
_(Portal-aware apps changed, config apps not yet set. Scheme-aware sites are updated
without page refresh.)_
2. Specific application styles are set. For now the list is small, including `kitty`,
`waybar`, and `sway`. `kitty` is the only type (2) application here, whereas the other
two are type (1).
a. For the type (1) apps, the canonical config files as specified in the app registry
are symlinked to their light variants. For `sway`, this is `~/.config/sway/config`,
and if we look at the `file`:
```sh
.config/sway/config: symbolic link to ~/.config/sway/sway-tone4-light
```
b. For the type (2) apps, just the `current-theme.conf` file is symlinked to the
relevant palette-scheme file. `kitty` is such an app, with a supported theme file
for `tone4`, and those files have been auto-generated via `gen_theme.py`. Looking at
this file under the `kitty` config directory:
```sh
.config/kitty/current-theme.conf: symbolic link to ~/Documents/projects/autoconf/autoconf/themes/tone4/apps/kitty/generated/light.conf
```
The `kitty.conf` file isn't changed, as all palette-related items are specified in
the theme file. (Note that the general notion of a "theme" could include changes to
other stylistic aspects, like the font family; this would likely require some hybrid
type 1-2 approach not yet implemented).
3. Live application instances are reloaded, according to the registered `refresh_cmd`s.
Here the apps with style/config files set in step (2) are reloaded to reflect those
changes. Again, in this example, this is `kitty`, `sway`, and the `waybar`.
![Final light setting: portal-dependent apps _and_ config-based apps changed](_static/set-theme-3.png)
_(Final light setting: portal-dependent apps _and_ config-based apps changed)_
4. `set_theme.py` provides a report for the actions taken; in this case, the following was
printed:
![`set_theme.py` output](_static/set-theme-4.png)
_(`set_theme.py` output)_
[1]: https://github.com/ologio/monobiome

View File

@ -52,3 +52,4 @@
- Refresh scripts should likely specify a shell shabang at the top of the file
- `apps` can serve as a dotfiles folder
- Support symlinking whole folders?
- `any` might prefer to match configs with none over specific options, but will match any

View File

@ -1,5 +1,3 @@
- Push scheme generation to `~/.config/autoconf`
- Copy default app registry to the config location
- Formalize the theme spec (JSON) and `autoconf gen` to produce color configs
- Likely need to formalize the `sync.sh` script logic better, possibly associated directly
with registry TOML
- 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

View File

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

View File

@ -1,102 +0,0 @@
import argparse
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_set_subparser(subparsers)
if __name__ == '__main__':
args = parser.parse_args()
if 'func' in args:
args.func(args)
else:
parser.print_help()

View File

@ -1,370 +0,0 @@
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,74 +0,0 @@
import argparse
import inspect
import json
import tomllib as toml
from pathlib import Path
# separation sequences to use base on app
app_sep_map = {
'kitty': ' ',
}
def generate_theme_files():
basepath = get_running_path()
# set arg conditional variables
palette_path = Path(basepath, 'themes', args.palette)
colors_path = Path(palette_path, 'colors.json')
theme_app = args.app
template_path = None
output_path = None
if args.template is None:
template_path = Path(palette_path, 'apps', theme_app, 'templates')
else:
template_path = Path(args.template).resolve()
if args.output is None:
output_path = Path(palette_path, 'apps', theme_app, 'generated')
else:
output_path = Path(args.output).resolve()
# check paths
if not colors_path.exists():
print(f'Resolved colors path [{colors_path}] doesn\'t exist, exiting')
return
if not template_path.exists():
print(f'Template path [{template_path}] doesn\'t exist, exiting')
return
if not output_path.exists() or not output_path.is_dir():
print(f'Output path [{output_path}] doesn\'t exist or not a directory, exiting')
return
print(f'Using palette colors [{colors_path}]')
print(f'-> with templates in [{template_path}]')
print(f'-> to output path [{output_path}]\n')
# load external files (JSON, TOML)
colors_json = json.load(colors_path.open())
# get all matching TOML files
template_list = [template_path]
if template_path.is_dir():
template_list = template_path.rglob('*.toml')
for template_path in template_list:
template_toml = toml.load(template_path.open('rb'))
# lookup app-specific config separator
config_sep = app_sep_map.get(theme_app, ' ')
output_lines = []
for config_key, color_key in template_toml.items():
color_value = colors_json
for _key in color_key.split('.'):
color_value = color_value.get(_key, {})
output_lines.append(f'{config_key}{config_sep}{color_value}')
output_file = Path(output_path, template_path.stem).with_suffix('.conf')
output_file.write_text('\n'.join(output_lines))
print(f'[{len(output_lines)}] lines written to [{output_file}] for app [{theme_app}]')

View File

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

20
docs/Makefile Normal file
View File

@ -0,0 +1,20 @@
# Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line, and also
# from the environment for the first two.
SPHINXOPTS ?=
SPHINXBUILD ?= sphinx-build
SOURCEDIR = .
BUILDDIR = _build
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

BIN
docs/_static/example.gif vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 MiB

View File

@ -3,36 +3,47 @@
# For the full list of built-in configuration values, see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html
# -- Project information -----------------------------------------------------
# -- Project information ------------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
project = '<project-name>'
copyright = '2024, Sam Griesemer'
author = 'Sam Griesemer'
project = "symconf"
copyright = "2025, Sam Griesemer"
author = "Sam Griesemer"
# -- General configuration ---------------------------------------------------
# -- General configuration ----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
extensions = [
"sphinx.ext.autodoc",
# enables a directive to be specified manually that gathers module/object
# summary details in a table
"sphinx.ext.autosummary",
# allow viewing source in the HTML pages
"sphinx.ext.viewcode",
# only really applies to manual docs; docstrings still need RST-like
"myst_parser",
# enables Google-style docstring formats
"sphinx.ext.napoleon",
# external extension that allows arg types to be inferred by type hints
"sphinx_autodoc_typehints",
]
autosummary_generate = True
autosummary_imported_members = True
templates_path = ['_templates']
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
# include __init__ definitions in autodoc
autodoc_default_options = {
"special-members": "__init__",
}
templates_path = ["_templates"]
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
# -- Options for HTML output -------------------------------------------------
# -- Options for HTML output --------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
html_theme = 'furo'
html_static_path = ['_static']
#html_sidebars = {
html_theme = "furo"
html_static_path = ["_static"]
# html_sidebars = {
# '**': ['/modules.html'],
#}
# }

View File

@ -1,29 +1,35 @@
# `autoconf` package docs
# `symconf` package
*General-purpose local application configuration manager*
{ref}`genindex`
{ref}`modindex`
{ref}`search`
```{eval-rst}
.. autosummary::
:nosignatures:
:recursive:
:caption: Modules
# list modules here for quick links
symconf.config
symconf.reader
symconf.runner
symconf.matching
symconf.template
```
```{toctree}
:maxdepth: 3
:caption: Autoref
_autoref/autoconf.rst
```
```{toctree}
:maxdepth: 3
:maxdepth: 1
:caption: Contents
:hidden:
reference/documentation/index
reference/site/index
reference/archive
reference/configuring
reference/matching
reference/usage
```
```{include} ../README.md
:relative-docs: docs/
:relative-images:
```

257
docs/reference/archive.md Normal file
View File

@ -0,0 +1,257 @@
# Archive
The `autoconf` project is an attempt at wrangling the complexity of configuring
many applications across one's Linux system. It provides a simple operational
model for pulling many application config files into one place, as well as
generating/setting color schemes across apps.
Quick terminology rundown for theme-related items:
- **Theme**: loose term referring generally to the overall aesthetic of a
visual setting. Ignoring stylistic changes (only applicable to some apps;
example here might be a a particular setting of the `waybar` layout), a theme
is often just the wrapper term for a choice of color _palette_ and _scheme_.
For example, "tone4-light" could be a _theme_ setting for an app like
`kitty`, referring to both a palette and scheme.
- **Palette**: a set of base colors used to style text or other aspects of an
app's displayed assets
- **Scheme**: an indication of lightness, typically either "light" or "dark.
As far as managing settings across apps, there are current two useful
classifications here:
1. **Inseparable from theme**: some apps (e.g., `sway`, `waybar`) have color
scheme components effectively built in to their canonical configuration
file. This can make it hard to set themes dynamically, as it would likely
require some involved matching/substitution rules. This is not a level of
complexity I'm willing to embrace, so we simply split the config files
according to theme and/or scheme.
2. **Can load an external theme file**: some apps (e.g., `kitty`) have a clear
mechanism for loading themes. This typically implies some distinct color
format, although usually somewhat easy to generate (don't have to navigate
non-color settings, for instance). Such apps allow for an even less
"invasive" config swapping process when setting a new theme, as one can just
swap out the external theme file.
To be clear on operation implications here: apps of type (1) must have
_manually maintained_ config variations according the desired themes. General
theme settings must follow the naming scheme
`<app-name>-<palette>-<scheme>.<ext>`. For example, if I wanted to set `sway`
to a light variation (which, at the time of writing, would just entail changing
a single background color), I must have explicitly created a
`sway-tone4-light.conf` file that captures this setting. The canonical config
file will then be symlinked to the theme-specific file when the theme is set.
(Note that the palette in this example is pretty much irrelevant, but it needs
to be present in order to match the overarching setting; here you can just
think of the format being `<app-name>-<theme>.<ext>`, where `tone4-light` is
the provided theme name.)
For apps of type (2), the canonical config file can remain untouched so long as
it refers to a fixed, generic theme file. For example, with `kitty`, my config
file can point to a `current-theme.conf` file, which will be symlinked to a
specific theme file here in `autoconf` when a change is requested. This enables
a couple of conveniences:
- The true config directory on disk remains unpolluted with theme variants.
- If the set theme is regenerated, there is no intervention necessary to
propagate its changes to the target app. The symlinked file itself will be
updated when the theme does, ensuring the latest theme version is always
immediately available and pointed to by the app.
Keep in mind that some apps may fall into some grey area here, allowing some
external customization but locking down other settings internally. In such
instances, there's no need to overcomplicate things; just stick to explicit
config variants under the type (1) umbrella. Type (2) only works for generated
themes anyhow; even if the target app can load an external theme, type (1)
should be used if preset themes are fixed.
## Naming standards
To keep things simple, we use a few fixed naming standards for setting app
config files and their themed counterparts. The app registry requires each
theme-eligible app to provide a config directory (`config_dir`), containing
some canonical config file (`config_file`) and to serve as a place for
theme-specific config variations. The following naming schemes must be used in
order for theme switching to behave appropriately:
- When setting a theme for a particular app, the following variables will be
available:
* `<app-name>`
* `<palette>`
* `<scheme>`
- For apps with `external_theme = False`, config variants must named as
`<app-name>-<palette>-<scheme>.<ext>`, where `<ext>` is the app's default
config file extension.
- For apps with `external_theme = True`, the file
`<config-dir>/current-theme.conf` will be used when symlinking the requested
theme. The config file thus must point to this file in order to change with
the set theme.
Additionally, the theme symlink will be created from the file
```
<autoconf-root>/autoconf/themes/<palette>/apps/<app-name>/generated/<scheme>.conf
```
to `<config-dir>/current-theme.conf`.
## Directory structure
- `autoconf/`: main repo directory
* `config/`: app-specific configuration files. Each folder inside this
directory is app-specific, and the target of associated copy operations
when a config sync is performed. Nothing in this directory should pertain
to any repo functionality; it should only contain config files that
originated elsewhere on the system.
* `themes/`: app-independent theme data files. Each folder in this
directory should correspond to a specific color palette and house any
relevant color spec files (currently likely be a `colors.json`). Also
servers the output location for generated theme files
* `<palette>/colors.json`: JSON formatted color key-value pairings for
palette colors. There's no standard here aside from the filename and
format; downstream app-specific TOML templates can be dependent on
any key naming scheme within the JSON.
+ `<palette>/apps/<app-name>/templates/`: houses the TOML maps for the
color palette `<palette>` under app `<app-name>`. Files
`<fname>.toml` will be mapped to `<fname>.conf` in the theme output
folder (below), so ensure the naming standards align with those
outlined above.
+ `<palette>/apps/<app-name>/generated/`: output directory for
generated scheme variants. These are the symlink targets for
dynamically set external themes.
* `app_registry.toml`: global application "registry" used by sync and
theme-setting scripts. This lets apps be dynamically added or removed
from being eligible for config-related operations.
## Scripts
`set_theme.py`: sets a theme across select apps.
- Applies to specific app with `-a <app>` , or to all apps in the
`app_registry.toml` with `-a "*"`.
- Uses symlinks to set canonical config files to theme-based variations. Which
files get set depends on the _app type_ (see above), which really just boils
down to whether theming (1) can be specified with an external format, and (2)
if it depends on auto-generated theme files from within `autoconf`.
- Palette and scheme are specified as expected. They are used to infer proper
paths according to naming and structure standards.
- Real config files will never be overwritten. To begin setting themes with the
script, you must delete the canonical config file expected by the app (and
specified in the app registry) to allow the first symlink to be set. From
there on out, symlinks will be automatically flushed.
- A report will be provided on which apps were successfully set to the
requested theme, along with the file stems. A number of checks are in place
for the existence of involved files and directories. Overall, the risk of
overwritting a real config file is low; we only flush existing symlinks, and
if the would-be target for the requested theme (be it from an auto-generated
theme file, or from a manually manage config variant) doesn't exist, that
app's config will be completed skipped. Essentially, everything must be in
perfect shape before the symlink trigger is officially pulled.
`gen_theme.py`: generates theme files for palettes by mapping their color
definitions through app-specific templates. These templates specific how to
relate an app's theme variables to the color names provided by the template.
- An app and palette are the two required parameters. If no template or output
paths are provided, they will be inferred according to the theme path
standards seen above.
- The `--template` argument can be a directory or a file, depending on what
theme files you'd like to render.
- The `--output` path, if specified, must be a directory. Generated theme files
take on a name with the same stem as their source template, but using the
`.conf` extension.
- The TOML templates should make config variable names to JSON dot-notation
accessors. If color definitions are nested, the dot notation should be
properly expanded by the script when mapping the colors to keyword values.
- There are a number of checks for existing paths, even those inferred (e.g.,
template and output) from the palette and app. If the appropriate setup
hasn't been followed, the script will fail. Make sure the `theme` folder in
question and it's nested `app` directory are correctly setup before running
the script. (Perhaps down the line there are some easy auto-setup steps to
take here, but I'm not making that jump now.)
- TODO: open up different app "writers," or make it easy to extend output
syntax based on the app in question. This would like be as simple as mapping
app names to line-generating functions, which accept the keyword and color
(among other items). This can be fleshed out as needed.
`sync.sh`: copies relevant configuration files from local paths into the
`autoconf` subpath. Markdown files in the docs directory then reference the
local copies of these files, meaning the documentation updates dynamically when
the configuration files do. That is, the (possibly extracted) config snippets
will change with the current state of my system config without any manual
intervention of the documentation files.
### Specific theme-setting example
To make clear how the theme setting script works on my system, the following
breaks down exactly what steps are taken to exert as much scheme control as
possible. Everything at this point is wrapped up in a single `make
set-<palette>-<scheme>` call; suppose we're currently running the dark scheme
(see first image) and I run `make set-tone4-light`:
![
Starting point; have a GTK app (GNOME files), `kitty`, and Firefox (with the
system-dependent default theme set). In Firefox, I have open `localsys` with
its scheme-mode to set to "auto," which should reflect the theme setting
picked up by the browser (and note the white tab icon).
](_static/set-theme-1.png)
_(Starting point; have a GTK app (GNOME files), `kitty`, and Firefox (with the
system-dependent default theme set). In Firefox, I have open `localsys` with
its scheme-mode to set to "auto," which should reflect the theme setting picked
up by the browser (and note the white tab icon).)_
1. `set_theme.py` is invoked. Global settings are applied first, based on my OS
(`Linux`), which calls
```
gsettings set org.gnome.desktop.interface color-scheme 'prefer-light'
```
controlling settings for GTK apps and other `desktop-portal`-aware programs.
This yields the following:
![Portal-aware apps changed, config apps not yet set](_static/set-theme-2.png)
_(Portal-aware apps changed, config apps not yet set. Scheme-aware sites are
updated without page refresh.)_
2. Specific application styles are set. For now the list is small, including
`kitty`, `waybar`, and `sway`. `kitty` is the only type (2) application
here, whereas the other two are type (1).
a. For the type (1) apps, the canonical config files as specified in the app
registry are symlinked to their light variants. For `sway`, this is
`~/.config/sway/config`, and if we look at the `file`:
```sh
.config/sway/config: symbolic link to ~/.config/sway/sway-tone4-light
```
b. For the type (2) apps, just the `current-theme.conf` file is symlinked to
the relevant palette-scheme file. `kitty` is such an app, with a
supported theme file for `tone4`, and those files have been
auto-generated via `gen_theme.py`. Looking at this file under the `kitty`
config directory:
```sh
.config/kitty/current-theme.conf: symbolic link to
~/Documents/projects/autoconf/autoconf/themes/tone4/apps/kitty/generated/light.conf
```
The `kitty.conf` file isn't changed, as all palette-related items are
specified in the theme file. (Note that the general notion of a "theme"
could include changes to other stylistic aspects, like the font family;
this would likely require some hybrid type 1-2 approach not yet
implemented).
3. Live application instances are reloaded, according to the registered
`refresh_cmd`s. Here the apps with style/config files set in step (2) are
reloaded to reflect those changes. Again, in this example, this is `kitty`,
`sway`, and the `waybar`.
![
Final light setting: portal-dependent apps _and_ config-based apps changed
](_static/set-theme-3.png)
_(Final light setting: portal-dependent apps _and_ config-based apps
changed)_
4. `set_theme.py` provides a report for the actions taken; in this case, the
following was printed:
![`set_theme.py` output](_static/set-theme-4.png)
_(`set_theme.py` output)_

View File

@ -0,0 +1,183 @@
# Configuring
`symconf` operates on a central directory that houses all of the config files
you may wish to apply. The default location for this directory is your
`$XDG_CONFIG_HOME` (e.g., `~/.config/symconf/`), but it can be any location on
your system so long as it's specified (see more in Usage).
`symconf` expects you to create two top-level components in your config
directory: an `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
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 `symconf`. Note that simply populating an app's config folder here will do
nothing on its own; the app must also have been registered (discussed in item
#2) in order for these files to be used when `symconf` is invoked. (This just
means you can populate your `apps/` folder safely without expecting any default
behavior. More often than not you'll be expected to tell `symconf` exactly
where your config files should end up, meaning you know exactly what it's
doing.)
### User config
Inside your app-specific subdirectory, your managed config files should be
placed in a `user/` subdirectory (distinguishing them from those generated by
templates; see more in Themes). Your config files themselves are then expected
to follow a specific naming scheme:
```sh
<palette>-<scheme>.<config-name>
```
This ties your config file to a particular theme setting as needed, and
`symconf` will apply it if it matches the theme setting you provide when
invoked. The specific values are as follows:
- `scheme`: can be `light`, `dark`, or `none`. Indicates whether the config
file should be applied specifically when requesting a light or dark mode. Use
`none` to indicate that the config file does not have settings specific to a
light/dark mode.
- `palette`: a "palette name" of your choosing, or `none`. The palette name you
use here may refer specifically to a color palette used by the config file,
but can be used generally to indicate any particular group of config settings
(e.g., fonts, transparency, etc). Use `none` to indicate that the file does
not correspond to any particular style group.
- `config-name`: the _name_ of the config file. This should correspond to _same
path name_ that is expected by the app being configured. For example, if your
app expects a 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), appropriate config files can be matched based on how you've named your
files.
For example, suppose I want to set up a simple light/dark mode switch for the
`kitty` terminal emulator. The following tree demonstrates a valid setup:
```sh
<config-home>
└── apps/
└── kitty/
└── user/
├── none-light.kitty.conf
└── none-dark.kitty.conf
```
where `none-light.kitty.conf` may set a light background and
`none-dark.kitty.conf` a dark one. `none` is used for the `<palette>` part of
the name to indicate the configuration does not pertain to any specific palette
and can be matched even if one is not provided. With an appropriate
`app_regsitry.toml` file (see below), invoking
```sh
symconf --theme=light --apps=kitty
```
would symlink `$XDG_CONFIG_HOME/symconf/apps/kitty/user/none-light.kitty.conf`
to `~/.config/kitty/kitty.conf`.
### Templatized config
Note the potential inconvenience in needing to manage two separate config files
in the above example, very likely with all but one line of difference.
Templating enables populating config template files dynamically with
theme-specific variables of your choosing.
### Reload scripts
After symlinking a new set of config files, it is often necessary to reload the
system or relevant apps in order for the new config settings to apply. Within
an app's subdirectory, a `call/` folder can be created to hold scripts that
should apply based on certain schemes or palettes (matching them in the same
way as config files). For example,
```sh
<config-home>
└── apps/
└── kitty/
└── call/
└── none-none.sh
```
`none-none.sh` might simply contain `kill -s USR1 $(pgrep kitty)`, which is a
way to tell all running `kitty` instances to reload their config settings.
Again, following the naming scheme for config files, a script named
`none-none.sh` will apply under any scheme or palette specification. Thus, in
our light/dark mode switch example, invoking `symconf --theme=light
--apps=kitty` would:
1. Search and match the config name `none-light.kitty.conf` and symlink it to
`~/.config/kitty/kitty.conf`.
2. Search and match `none-none.sh` and execute it, applying the new light mode
settings to all running `kitty` instances.
## App registry
An `app_registry.toml` file, used to specify the target locations for
app-specific config files. To "register" an app, you simply need to add the
following text block to the `app_registry.toml` file:
```toml
[app.<app-name>]
# DEFINE *ONE* OF THE FOLLOWING
config_dir = "<path-to-app-config-folder>"
# OR
config_map = {
"<conf-pathname-1>" = "<path-to-exact-config-file>"
"<conf-pathname-2>" = "<path-to-exact-config-file>"
# ...
}
```
(Note that text in angle brackets refers to values that should be replaced.)
This tells `symconf` how it should handle each app's config files. The
`<app-name>` (e.g., `kitty`) should correspond to a subdirectory under `apps/`
(e.g., `apps/kitty/`) that holds your config files for that app. As shown, you
then need to supply either of the following options:
- `config_dir`: specifies a single directory where all of the app's matching
config files should be symlinked. In the `kitty` example, this might be
`~/.config/kitty`. This is the simplest and most common option provided most
apps expect all of their config files to be in a single directory.
- `config_map`: a dictionary mapping config file _path names_ to the _exact
paths_ that should be created during the symlink process. This is typically
needed when an app has many config files that need to be set in several
disparate locations across your system. In the `kitty` example, although not
necessary (and in general you should prefer to set `config_dir` when
applicable), we could have the following `config_map`:
```toml
[app.kitty]
config_map = {
"kitty.conf": "~/.config/kitty/kitty.conf"
}
```
This tells `symconf` to symlink the exact location
`~/.config/kitty/kitty.conf` to the matching `kitty.conf` under the
`apps/kitty` directory.
## Directory structure
In total, the structure of your base config directory can look as follows:
```sh
~/.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
```
## Misc remarks
### Multiple config files with same path name

View File

@ -1,8 +1,5 @@
# Documentation
```{toctree}
:hidden:
sphinx
```

View File

@ -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
```

170
docs/reference/usage.md Normal file
View File

@ -0,0 +1,170 @@
# Usage
```sh
usage: symconf [-h] [-c CONFIG_DIR] [-v] {config,generate,install,update} ...
Manage application configuration with symlinks.
options:
-h, --help show this help message and exit
-c CONFIG_DIR, --config-dir CONFIG_DIR
Path to config directory
-v, --version Print symconf version
subcommand actions:
{config,generate,install,update}
```
Additional argument details:
- `-h --help`: print help message
- `-c --config-dir`: the location of the `symconf` config directory. Assumes
`$XDG_CONFIG_HOME` (e.g., `~/.config/symconf/`) by default.
## `config` subcommand
The config subcommand applies symlinks for registered application routes that
meet specified constraints.
```sh
usage: symconf config [-h] [-s STYLE] [-m MODE] [-a APPS] [-T TEMPLATE_VARS [TEMPLATE_VARS ...]]
Set config files for registered applications.
options:
-h, --help show this help message and exit
-s STYLE, --style STYLE
Style indicator (often a color palette) capturing thematic details in a config file
-m MODE, --mode MODE Preferred lightness mode/scheme, either "light," "dark," "any," or "none."
-a APPS, --apps APPS Application target for theme. App must be present in the registry. Use "*" to apply to all registered apps
-T TEMPLATE_VARS [TEMPLATE_VARS ...], --template-vars TEMPLATE_VARS [TEMPLATE_VARS ...]
Groups to use when populating templates, in the form group=value
```
- `symconf config` is the subcommand used to match and set available config
files for registered applications
* `-a --apps`: comma-separate list of registered apps, or `"*"` (default) to
consider all registered apps.
* `-m --mode`: preferred lightness mode/scheme, either `light`, `dark`,
`any`, or `none`.
* `-s --style`: style indicator, often the name of a color palette, capturing
thematic details in a config file to be matched. `any` or `none` are
reserved keywords (see below).
* `-T --template-vars`: additional groups to use when populating templates,
in the form `<group>=<value>`, where `<group>` is a template group with a
folder `$CONFIG_HOME/groups/<group>/` and `<value>` should correspond to a
TOML file in this folder (i.e., `<value>.toml`).
## `generate` subcommand
The generate subcommand fills in templates with theme values matched under
provided constraints,
```sh
usage: symconf generate [-h] -o OUTPUT_DIR [-s STYLE] [-m MODE] [-a APPS] [-T TEMPLATE_VARS [TEMPLATE_VARS ...]]
Generate all template config files for specified apps
options:
-h, --help show this help message and exit
-o OUTPUT_DIR, --output-dir OUTPUT_DIR
Path to write generated template files
-s STYLE, --style STYLE
Style indicator (often a color palette) capturing thematic details in a config file
-m MODE, --mode MODE Preferred lightness mode/scheme, either "light," "dark," "any," or "none."
-a APPS, --apps APPS Application target for theme. App must be present in the registry. Use "*" to apply to all registered apps
-T TEMPLATE_VARS [TEMPLATE_VARS ...], --template-vars TEMPLATE_VARS [TEMPLATE_VARS ...]
Groups to use when populating templates, in the form group=value
```
- `symconf generate` is a subcommand that can be used for batch generation of
config files. It accepts the same arguments as `symconf config`, but rather
than selecting the best match to be used for the system setting, all matching
templates are generated. There is one additional required argument:
* `-o --output-dir`: the directory under which generated config files should
be written. App-specific subdirectories are created to house config files
for each provided app.
## `install` subcommand
The install subcommand runs the `install.sh` scripts for registered apps.
```sh
usage: symconf install [-h] [-a APPS]
Run install scripts for registered applications.
options:
-h, --help show this help message and exit
-a APPS, --apps APPS Application target for theme. App must be present in the registry. Use "*" to apply to all registered apps
```
- `symconf install`: runs install scripts for matching apps
* `-a --apps`: comma-separate list of registered apps, or `"*"` (default) to
consider all registered apps.
## `update` subcommand
The update subcommand runs the `update.sh` scripts for registered apps. This
action expects the install process for each matched app to have been run
beforehand.
```sh
usage: symconf update [-h] [-a APPS]
Run update scripts for registered applications.
options:
-h, --help show this help message and exit
-a APPS, --apps APPS Application target for theme. App must be present in the registry. Use "*" to apply to all registered apps
```
- `symconf update`: runs update scripts for matching apps that specify one
* `-a --apps`: comma-separate list of registered apps, or `"*"` (default) to
consider all registered apps.
## Matching factors
The keywords `any` and `none` can be used when specifying `--mode`, `--style`,
or as a value in `--template-vars` (and we refer to each of these variables as
_factors_ that help determine a config match):
- `any` will match config files with _any_ value for this factor, preferring
config files with a value `none`, indicating no dependence on the factor.
This is the default value when a factor is left unspecified.
- `none` will match `"none"` directly for a given factor (so no special
behavior), but used to indicate that a config file is independent of the
factor. For instance,
```sh
symconf config -m light -s none
```
will match config files that capture the notion of a light mode, but do not
depend on or provide further thematic components such as a color palette.
## Examples
- Set a dark mode for all registered apps, matching any available style/palette
component:
```sh
symconf config -m dark
```
- Set `solarized` theme for `kitty` and match any available mode (light or
dark):
```sh
symconf config -s solarized -a kitty
```
- Set a dark `gruvbox` theme for multiple apps (but not all):
```sh
symconf config -m dark -s gruvbox -apps="kitty,nvim"
```
- Set a dark `gruvbox` theme for all apps, and attempt to match other template
elements:
```sh
symconf config -m dark -s gruvbox -T font=mono window=sharp
```
which would attempt to find and load key-value pairs in the files
`$CONFIG_HOME/groups/font/mono.toml` and
`$CONFIG_HOME/groups/window/sharp.toml` to be used as values when filling
templatized config files.

View File

@ -1,41 +1,46 @@
[build-system]
requires = ["setuptools", "wheel", "setuptools-git-versioning>=2.0,<3"]
requires = ["setuptools", "wheel"]
build-backend = "setuptools.build_meta"
[tool.setuptools-git-versioning]
enabled = true
[project]
name = "symconf"
version = "0.8.3"
description = "Local app configuration manager"
readme = "README.md"
requires-python = ">=3.12"
dynamic = ["version"]
#license = {file = "LICENSE"}
authors = [
{ name="Sam Griesemer", email="samgriesemer+git@gmail.com" },
]
keywords = ["config"]
readme = "README.md"
license = "MIT"
keywords = ["tempate-engine", "theme-switcher", "configuration-files"]
classifiers = [
"Programming Language :: Python :: 3.12",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python",
"Operating System :: OS Independent",
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"Intended Audience :: End Users/Desktop",
]
dependencies = [
"pyxdg",
"colorama",
]
[project.scripts]
symconf = "symconf.__main__:main"
[project.optional-dependencies]
tests = ["pytest"]
docs = [
doc = [
"sphinx",
"sphinx-togglebutton",
"sphinx-autodoc-typehints",
"furo",
"myst-parser",
]
dev = [
"pytest"
]
[project.urls]
Homepage = "https://doc.olog.io/symconf"
@ -43,6 +48,25 @@ Documentation = "https://doc.olog.io/symconf"
Repository = "https://git.olog.io/olog/symconf"
Issues = "https://git.olog.io/olog/symconf/issues"
[tool.setuptools.packages.find]
include = ["symconf*"] # pattern to match package names
[tool.ruff]
line-length = 79
[tool.ruff.lint]
select = ["ANN", "E", "F", "UP", "B", "SIM", "I", "C4", "PERF"]
[tool.ruff.lint.isort]
length-sort = true
order-by-type = false
force-sort-within-sections = false
[tool.ruff.lint.per-file-ignores]
"tests/**" = ["S101"]
"**/__init__.py" = ["F401"]
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
docstring-code-format = true

10
symconf/__init__.py Normal file
View File

@ -0,0 +1,10 @@
from importlib.metadata import version
from symconf import util, config, reader, matching, template
from symconf.config import ConfigManager
from symconf.reader import DictReader
from symconf.runner import Runner
from symconf.matching import Matcher, FilePart
from symconf.template import Template, FileTemplate, TOMLTemplate
__version__ = version("symconf")

214
symconf/__main__.py Normal file
View File

@ -0,0 +1,214 @@
from argparse import Namespace, ArgumentParser
from symconf import util, __version__
from symconf.config import ConfigManager
def add_install_subparser(subparsers: ArgumentParser) -> None:
def install_apps(args: Namespace) -> None:
cm = ConfigManager(args.config_dir)
cm.install_apps(apps=args.apps)
parser = subparsers.add_parser(
"install",
description="Run install scripts for registered applications.",
)
parser.add_argument(
"-a",
"--apps",
required=False,
default="*",
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=install_apps)
def add_update_subparser(subparsers: ArgumentParser) -> None:
def update_apps(args: Namespace) -> None:
cm = ConfigManager(args.config_dir)
cm.update_apps(apps=args.apps)
parser = subparsers.add_parser(
"update", description="Run update scripts for registered applications."
)
parser.add_argument(
"-a",
"--apps",
required=False,
default="*",
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_apps)
def add_config_subparser(subparsers: ArgumentParser) -> None:
def configure_apps(args: Namespace) -> None:
cm = ConfigManager(args.config_dir)
cm.configure_apps(
apps=args.apps,
scheme=args.mode,
style=args.style,
**args.template_vars,
)
parser = subparsers.add_parser(
"config", description="Set config files for registered applications."
)
parser.add_argument(
"-s",
"--style",
required=False,
default="any",
help=(
"Style indicator (often a color palette) capturing "
"thematic details in a config file"
),
)
parser.add_argument(
"-m",
"--mode",
required=False,
default="any",
help=(
'Preferred lightness mode/scheme, either "light," "dark," '
'"any," or "none."'
),
)
parser.add_argument(
"-a",
"--apps",
required=False,
default="*",
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.add_argument(
"-T",
"--template-vars",
required=False,
nargs="+",
default={},
action=util.KVPair,
help=(
"Groups to use when populating templates, in the form group=value"
),
)
parser.set_defaults(func=configure_apps)
def add_generate_subparser(subparsers: ArgumentParser) -> None:
def generate_apps(args: Namespace) -> None:
cm = ConfigManager(args.config_dir)
cm.generate_app_templates(
gen_dir=args.output_dir,
apps=args.apps,
scheme=args.mode,
style=args.style,
**args.template_vars,
)
parser = subparsers.add_parser(
"generate",
description="Generate all template config files for specified apps",
)
parser.add_argument(
"-o",
"--output-dir",
required=True,
type=util.absolute_path,
help="Path to write generated template files",
)
parser.add_argument(
"-s",
"--style",
required=False,
default="any",
help=(
"Style indicator (often a color palette) capturing "
"thematic details in a config file"
),
)
parser.add_argument(
"-m",
"--mode",
required=False,
default="any",
help=(
'Preferred lightness mode/scheme, either "light," "dark," '
'"any," or "none."'
),
)
parser.add_argument(
"-a",
"--apps",
required=False,
default="*",
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.add_argument(
"-T",
"--template-vars",
required=False,
nargs="+",
default={},
action=util.KVPair,
help=(
"Groups to use when populating templates, in the form group=value"
),
)
parser.set_defaults(func=generate_apps)
# central argparse entry point
parser = ArgumentParser(
"symconf", description="Manage application configuration with symlinks."
)
parser.add_argument(
"-c",
"--config-dir",
default=util.xdg_config_path(),
type=util.absolute_path,
help="Path to config directory",
)
parser.add_argument(
"-v",
"--version",
action="version",
version=__version__,
help="Print symconf version",
)
# add subparsers
subparsers = parser.add_subparsers(title="subcommand actions")
add_config_subparser(subparsers)
add_generate_subparser(subparsers)
add_install_subparser(subparsers)
add_update_subparser(subparsers)
def main() -> None:
args = parser.parse_args()
if "func" in args:
args.func(args)
else:
parser.print_help()
if __name__ == "__main__":
main()

999
symconf/config.py Normal file
View File

@ -0,0 +1,999 @@
"""
Primary config management abstractions
The config map is a dict mapping from config file **path names** to their
absolute path locations. That is,
.. code-block:: sh
<config_path_name>
->
<config_dir>/apps/<app_name>/<subdir>/<palette>-<scheme>.<config_path_name>
For example,
.. code-block:: sh
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 sys
import tomllib
import subprocess
from pathlib import Path
from colorama import Fore, Style
from symconf import util
from symconf.util import printc, color_text
from symconf.runner import Runner
from symconf.matching import Matcher, FilePart
from symconf.template import FileTemplate, TOMLTemplate
class ConfigManager:
def __init__(
self,
config_dir: str | Path | None = None,
disable_registry: bool = False,
) -> None:
"""
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 is 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) -> None:
"""
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) -> None:
"""
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"
f' 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: str, value: str = "auto") -> str:
"""
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]],
user: str | None = None,
) -> None:
"""
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():
reason = f'Config path "{to_path}" doesn\'t exist, skipping'
links_fail.append((from_path, to_path, reason))
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():
reason = (
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, reason))
continue
try:
self.symlink(from_path, to_path, user)
links_succ.append((from_path, to_path))
except Exception as e:
reason = f"Symlink failed: {e}"
links_fail.append((from_path, to_path, reason))
# 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)} "
f"-> {color_text(to_p, Style.BRIGHT)}",
Fore.GREEN,
),
)
for from_p, to_p, reason 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),
)
print(
color_text("", Fore.BLUE),
color_text(f" > {reason}", Fore.RED + Style.DIM),
)
def _matching_template_groups(
self,
scheme: str = "auto",
style: str = "auto",
**kw_groups: dict,
) -> 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
.. code-block:: sh
<style>-<scheme>.toml
The only difference is that, while ``style`` can still include
arbitrary style variants, it *must* have the form
.. code-block:: 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
prefix_order = [fval, "none"] if fval == "any" else ["none", fval]
# 2) fully enumerate matches, including "any"
matches = []
for prefix in prefix_order:
matches.extend(
[
stem
for stem in stem_map
if prefix == stem or prefix == "any"
]
)
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 _prepare_all_templates(
self,
scheme: str = "any",
style: str = "any",
) -> dict[str, dict]:
palette_map = {}
palette_group_dir = Path(self.group_dir, "palette")
if palette_group_dir.exists():
for palette_path in palette_group_dir.iterdir():
palette_map[palette_path.stem] = palette_path
palette_base = []
if "none" in palette_map:
palette_base.append(palette_map["none"])
# 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( # reg non-template order
scheme,
style,
strict=True, # set strict=True to ignore "nones"
),
)
theme_map = {}
for fp in theme_matches:
# still look through whole theme dir here (eg to match nones)
theme_matches = self.matcher.match_paths(
theme_group_dir.iterdir(), # match files in groups/theme/
self.matcher.prefix_order(
fp.scheme, fp.style
), # reg non-template order
)
relaxed_theme_matches = self.matcher.relaxed_match(theme_matches)
palette = fp.style.split("-")[-1]
palette_paths = [*palette_base]
if palette in palette_map:
palette_paths.append(palette_map[palette])
theme_dict = {}
palette_dict = TOMLTemplate.stack_toml(palette_paths)
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)
theme_map[fp.path.stem] = {"theme": theme_dict}
return theme_map
def get_matching_configs(
self,
app_name: str,
scheme: str = "auto",
style: str = "auto",
strict: bool = 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: str,
scheme: str = "auto",
style: str = "auto",
**kw_groups: dict,
) -> 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: str,
scheme: str = "any",
style: str = "any",
) -> list[FilePart]:
"""
Execute matching scripts in the app's ``call/`` directory.
Scripts need to be placed in
.. code-block:: 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: dict,
) -> None:
"""
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
# get possibly specified user
user = app_settings.get("user")
# 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 = [f.path for f in script_list]
# print match messages
num_links = len(to_symlink)
num_scripts = len(script_list)
if user is None:
print(
color_text("├─", Fore.BLUE),
f"{app_name} :: matched {num_links} config files, "
f"{num_scripts} scripts",
)
else:
print(
color_text("├─", Fore.BLUE),
f"{app_name}@user:{user} :: matched {num_links} config files, "
f"{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 '
f"[{rel_theme_matches}]",
Style.DIM,
),
)
self._symlink_paths(to_symlink, user)
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: dict,
) -> None:
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("> 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: str,
apps: str | list[str] = "*",
) -> None:
"""
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] = "*",
) -> None:
self._app_action("install.sh", apps)
def update_apps(
self,
apps: str | list[str] = "*",
) -> None:
self._app_action("update.sh", apps)
def generate_app_templates(
self,
gen_dir: str | Path,
apps: str | list[str] = "*",
scheme: str = "any",
style: str = "any",
**kw_groups: dict,
) -> None:
if apps == "*":
app_list = list(self.app_registry.keys())
else:
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("> symconf parameters: ")
print(f" > registered apps :: {color_text(app_list, Fore.YELLOW)}")
print("> Writing templates...")
gen_dir = util.absolute_path(gen_dir)
theme_map = self._prepare_all_templates(scheme, style)
for app_name in app_list:
app_template_dir = Path(self.apps_dir, app_name, "templates")
if not app_template_dir.exists():
continue
app_template_files = list(app_template_dir.iterdir())
self.get_matching_templates(
app_name, scheme=scheme, style=style, **kw_groups
)
num_temps = len(app_template_files)
num_themes = len(theme_map)
print(
color_text("├─", Fore.BLUE),
f"{app_name} :: generating ({num_temps}) "
f"templates from ({num_themes}) themes",
)
for template_file in app_template_files:
app_template = FileTemplate(template_file)
for theme_stem, theme_dict in theme_map.items():
tgt_template_dir = Path(gen_dir, app_name)
tgt_template_dir.mkdir(parents=True, exist_ok=True)
tgt_template_path = Path(
tgt_template_dir, f"{theme_stem}.{template_file.name}"
)
filled_template = app_template.fill(theme_dict)
tgt_template_path.write_text(filled_template)
print(
color_text("", Fore.BLUE),
f'> generating "{tgt_template_path.name}"',
)
def symlink(
self,
from_path: Path,
to_path: Path,
user: str | None = None,
) -> None:
# attempt in-built pathlib symlink
if user is None:
# create parent directory if doesn't exist
from_path.parent.mkdir(parents=True, exist_ok=True)
# if path doesn't exist, or exists and is a symlink, remove the
# symlink in preparation for the new symlink setting
from_path.unlink(missing_ok=True)
# attempt to set symlink
Path(from_path).symlink_to(Path(to_path))
return
# otherwise bottle up and run as specified user w/ permissions
compact_symlink_py = (
"import os,sys,pathlib;"
"p=pathlib.Path(sys.argv[1]).parent;"
"p.mkdir(parents=True,exist_ok=True);"
"pathlib.Path(sys.argv[1]).unlink(missing_ok=True);"
"pathlib.Path(sys.argv[1]).symlink_to(sys.argv[2])"
)
sudo_prompt = (
color_text("│ > ", Fore.BLUE)
+ color_text(
f"[symlinks require {user} permissions]",
Fore.RED + Style.BRIGHT,
)
+ color_text(" password for %p: ", Fore.RED)
)
subprocess.run(
[
"sudo",
"-u",
user,
"-p",
sudo_prompt,
sys.executable,
"-c",
compact_symlink_py,
str(from_path),
str(to_path),
],
check=True,
)

267
symconf/matching.py Normal file
View File

@ -0,0 +1,267 @@
"""
Generic combinatorial name-matching subsystem
Config files are expected to have names matching the following spec:
.. code-block:: sh
<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
.. code-block:: sh
soft-gruvbox-dark.kitty.conf
gets mapped to
.. code-block:: 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) -> None:
self.path = util.absolute_path(path)
self.pathname = self.path.name
parts = str(self.pathname).split(".")
if len(parts) < 2:
raise ValueError(
f'Filename "{self.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) -> None:
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
.. code-block:: 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:
print(f'Filename "{path}" incorrectly formatted, ignoring')
return file_parts
def prefix_order(
self,
scheme: str,
style: str,
strict: bool = 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
.. code-block:: python
[("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())

105
symconf/reader.py Normal file
View File

@ -0,0 +1,105 @@
"""
Simplified management for nested dictionaries
"""
import copy
import pprint
import hashlib
import logging
import tomllib
from typing import Any
from pathlib import Path
from symconf.util import deep_update
logger = logging.getLogger(__name__)
class DictReader:
def __init__(self, toml_path: str | None = None) -> None:
self._config = {}
self.toml_path = toml_path
if toml_path is not None:
self._config = self._load_toml(toml_path)
def __str__(self) -> str:
return pprint.pformat(self._config, indent=4)
@staticmethod
def _load_toml(toml_path: str) -> dict[str, Any]:
return tomllib.loads(Path(toml_path).read_text())
@classmethod
def from_dict(cls, config_dict: dict) -> "DictReader":
new_instance = cls()
new_instance._config = copy.deepcopy(config_dict)
return new_instance
def update(
self, config: "DictReader", in_place: bool = False
) -> "DictReader":
new_config = deep_update(self._config, config._config)
if in_place:
self._config = new_config
return self
return self.from_dict(new_config)
def copy(self) -> "DictReader":
return self.from_dict(copy.deepcopy(self._config))
def get_subconfig(self, key: str) -> "DictReader":
pass
def get(self, key: str, default: str | None = None) -> str:
keys = key.split(".")
subconfig = self._config
for subkey in keys[:-1]:
subconfig = subconfig.get(subkey)
if type(subconfig) is not dict:
return default
return subconfig.get(keys[-1], default)
def set(self, key: str, value: str) -> bool:
keys = key.split(".")
subconfig = self._config
for subkey in keys[:-1]:
if subkey in subconfig:
subconfig = subconfig[subkey]
if type(subconfig) is not dict:
logger.debug(
"Attempting to set nested key with an "
"existing non-dict parent"
)
return False
continue
subdict = {}
subconfig[subkey] = subdict
subconfig = subdict
subconfig.update({keys[-1]: value})
return True
def generate_hash(self, exclude_keys: list[str] | None = None) -> str:
inst_copy = self.copy()
if exclude_keys is not None:
for key in exclude_keys:
inst_copy.set(key, None)
items = inst_copy._config.items()
# create hash from config options
config_str = str(sorted(items))
return hashlib.md5(config_str.encode()).hexdigest()

64
symconf/runner.py Normal file
View File

@ -0,0 +1,64 @@
"""
Handle job/script execution
"""
import stat
import subprocess
from pathlib import Path
from colorama import Fore, Style
from symconf.util import color_text
class Runner:
def run_script(
self,
script: str | Path,
) -> str | None:
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],
) -> list[str | None]:
outputs = []
for script in script_list:
output = self.run_script(script)
outputs.append(output)
return outputs

108
symconf/template.py Normal file
View File

@ -0,0 +1,108 @@
"""
Support for basic config templates
"""
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,
key_pattern: str = r"f{{(\S+?)}}",
exe_pattern: str = r"x{{((?:(?!x{{).)*)}}",
) -> None:
self.template_str = template_str
self.key_pattern = key_pattern
self.exe_pattern = exe_pattern
def fill(
self,
template_dict: dict,
) -> str:
dr = DictReader.from_dict(template_dict)
exe_filled = re.sub(
self.exe_pattern,
lambda m: self._exe_fill(m, dr),
self.template_str,
)
key_filled = re.sub(
self.key_pattern, lambda m: self._key_fill(m, dr), exe_filled
)
return key_filled
def _key_fill(
self,
match: re.Match,
dict_reader: DictReader,
) -> str:
key = match.group(1)
return str(dict_reader.get(key))
def _exe_fill(
self,
match: re.Match,
dict_reader: DictReader,
) -> str:
key_fill = re.sub(
self.key_pattern,
lambda m: f'"{self._key_fill(m, dict_reader)}"',
match.group(1),
)
return str(eval(key_fill))
class FileTemplate(Template):
def __init__(
self,
path: Path,
key_pattern: str = r"f{{(\S+?)}}",
exe_pattern: str = r"x{{((?:(?!x{{).)*)}}",
) -> None:
super().__init__(
path.open("r").read(),
key_pattern=key_pattern,
exe_pattern=exe_pattern,
)
class TOMLTemplate(FileTemplate):
def __init__(
self,
toml_path: Path,
key_pattern: str = r"f{{(\S+?)}}",
exe_pattern: str = r"x{{((?:(?!x{{).)*)}}",
) -> None:
super().__init__(
toml_path,
key_pattern=key_pattern,
exe_pattern=exe_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

102
symconf/util.py Normal file
View File

@ -0,0 +1,102 @@
import re
from pathlib import Path
from argparse import Action, Namespace, ArgumentParser
from xdg import BaseDirectory
from colorama import Back, Fore, Style
from colorama.ansi import AnsiCodes
def color_text(text: str, *colorama_args: AnsiCodes) -> str:
"""
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: str, *colorama_args: AnsiCodes) -> None:
print(color_text(text, *colorama_args))
def absolute_path(path: str | Path) -> Path:
return Path(path).expanduser().absolute()
def xdg_config_path() -> Path:
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:
"""Code adapted from pydantic"""
updated_mapping = mapping.copy()
for updating_mapping in updating_mappings:
for k, v in updating_mapping.items():
if (
k in updated_mapping
and isinstance(updated_mapping[k], dict)
and isinstance(v, dict)
):
updated_mapping[k] = deep_update(updated_mapping[k], v)
else:
updated_mapping[k] = v
return updated_mapping
class KVPair(Action):
def __call__(
self,
parser: ArgumentParser,
namespace: Namespace,
values: list[str],
option_string: str | None = None,
) -> None:
kv_dict = getattr(namespace, self.dest, {})
if kv_dict is None:
kv_dict = {}
for value in values:
key, val = value.split("=", 1)
kv_dict[key] = val
setattr(namespace, self.dest, kv_dict)

View File

@ -0,0 +1,2 @@
[app.test]
config_dir = 'sym_tgt/test'

View File

@ -1 +0,0 @@
gsettings set org.gnome.desktop.interface color-scheme 'prefer-dark'

View File

@ -1 +0,0 @@
gsettings set org.gnome.desktop.interface color-scheme 'prefer-light'

View File

@ -1 +0,0 @@
echo "> Testing script"

View File

@ -0,0 +1 @@
echo "none-light ran"

View File

@ -0,0 +1 @@
echo "none-none ran"

View File

@ -0,0 +1 @@
echo "> test-none ran"

View File

@ -0,0 +1 @@
base = "aaa"

View File

@ -0,0 +1 @@
concrete = "zzz"

View File

@ -0,0 +1 @@
/home/smgr/Documents/projects/olog/symconf/tests/test-config-dir/apps/test/user/none-none.aaa

View File

@ -0,0 +1 @@
/home/smgr/Documents/projects/olog/symconf/tests/test-config-dir/apps/test/user/test-dark.bbb

View File

@ -0,0 +1 @@
/home/smgr/Documents/projects/olog/symconf/tests/test-config-dir/apps/test/user/test-light.ccc

View File

@ -0,0 +1 @@
/home/smgr/Documents/projects/olog/symconf/tests/test-config-dir/apps/test/generated/test-none.ddd

View File

@ -1,5 +0,0 @@
def test_imports():
from autoconf import ConfigManager
from autoconf import config
from autoconf import theme

130
tests/test_matching.py Normal file
View File

@ -0,0 +1,130 @@
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() -> None:
"""
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() -> None:
"""
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() -> None:
"""
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() -> None:
"""
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 [p.pathname for p in 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 [p.pathname for p in 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 [p.pathname for p in any_dark] == [
"test-none.sh",
"none-none.sh",
]

48
tests/test_template.py Normal file
View File

@ -0,0 +1,48 @@
from pathlib import Path
from symconf import Template, TOMLTemplate
def test_template_fill() -> None:
# 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() -> None:
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"}

589
uv.lock generated Normal file
View File

@ -0,0 +1,589 @@
version = 1
revision = 3
requires-python = ">=3.12"
[[package]]
name = "accessible-pygments"
version = "0.0.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bc/c1/bbac6a50d02774f91572938964c582fff4270eee73ab822a4aeea4d8b11b/accessible_pygments-0.0.5.tar.gz", hash = "sha256:40918d3e6a2b619ad424cb91e556bd3bd8865443d9f22f1dcdf79e33c8046872", size = 1377899, upload-time = "2024-05-10T11:23:10.216Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8d/3f/95338030883d8c8b91223b4e21744b04d11b161a3ef117295d8241f50ab4/accessible_pygments-0.0.5-py3-none-any.whl", hash = "sha256:88ae3211e68a1d0b011504b2ffc1691feafce124b845bd072ab6f9f66f34d4b7", size = 1395903, upload-time = "2024-05-10T11:23:08.421Z" },
]
[[package]]
name = "alabaster"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" },
]
[[package]]
name = "babel"
version = "2.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852, upload-time = "2025-02-01T15:17:41.026Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" },
]
[[package]]
name = "beautifulsoup4"
version = "4.13.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "soupsieve" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/85/2e/3e5079847e653b1f6dc647aa24549d68c6addb4c595cc0d902d1b19308ad/beautifulsoup4-4.13.5.tar.gz", hash = "sha256:5e70131382930e7c3de33450a2f54a63d5e4b19386eab43a5b34d594268f3695", size = 622954, upload-time = "2025-08-24T14:06:13.168Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/eb/f4151e0c7377a6e08a38108609ba5cede57986802757848688aeedd1b9e8/beautifulsoup4-4.13.5-py3-none-any.whl", hash = "sha256:642085eaa22233aceadff9c69651bc51e8bf3f874fb6d7104ece2beb24b47c4a", size = 105113, upload-time = "2025-08-24T14:06:14.884Z" },
]
[[package]]
name = "certifi"
version = "2025.8.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.4.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", size = 205655, upload-time = "2025-08-09T07:56:08.475Z" },
{ url = "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", size = 146223, upload-time = "2025-08-09T07:56:09.708Z" },
{ url = "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", size = 159366, upload-time = "2025-08-09T07:56:11.326Z" },
{ url = "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", size = 157104, upload-time = "2025-08-09T07:56:13.014Z" },
{ url = "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", size = 151830, upload-time = "2025-08-09T07:56:14.428Z" },
{ url = "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", size = 148854, upload-time = "2025-08-09T07:56:16.051Z" },
{ url = "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", size = 160670, upload-time = "2025-08-09T07:56:17.314Z" },
{ url = "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", size = 158501, upload-time = "2025-08-09T07:56:18.641Z" },
{ url = "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", size = 153173, upload-time = "2025-08-09T07:56:20.289Z" },
{ url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822, upload-time = "2025-08-09T07:56:21.551Z" },
{ url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543, upload-time = "2025-08-09T07:56:23.115Z" },
{ url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326, upload-time = "2025-08-09T07:56:24.721Z" },
{ url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008, upload-time = "2025-08-09T07:56:26.004Z" },
{ url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196, upload-time = "2025-08-09T07:56:27.25Z" },
{ url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819, upload-time = "2025-08-09T07:56:28.515Z" },
{ url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350, upload-time = "2025-08-09T07:56:29.716Z" },
{ url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644, upload-time = "2025-08-09T07:56:30.984Z" },
{ url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468, upload-time = "2025-08-09T07:56:32.252Z" },
{ url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187, upload-time = "2025-08-09T07:56:33.481Z" },
{ url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699, upload-time = "2025-08-09T07:56:34.739Z" },
{ url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580, upload-time = "2025-08-09T07:56:35.981Z" },
{ url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366, upload-time = "2025-08-09T07:56:37.339Z" },
{ url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342, upload-time = "2025-08-09T07:56:38.687Z" },
{ url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995, upload-time = "2025-08-09T07:56:40.048Z" },
{ url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640, upload-time = "2025-08-09T07:56:41.311Z" },
{ url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636, upload-time = "2025-08-09T07:56:43.195Z" },
{ url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939, upload-time = "2025-08-09T07:56:44.819Z" },
{ url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580, upload-time = "2025-08-09T07:56:46.684Z" },
{ url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870, upload-time = "2025-08-09T07:56:47.941Z" },
{ url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797, upload-time = "2025-08-09T07:56:49.756Z" },
{ url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224, upload-time = "2025-08-09T07:56:51.369Z" },
{ url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086, upload-time = "2025-08-09T07:56:52.722Z" },
{ url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400, upload-time = "2025-08-09T07:56:55.172Z" },
{ url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "docutils"
version = "0.21.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444, upload-time = "2024-04-23T18:57:18.24Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" },
]
[[package]]
name = "furo"
version = "2025.9.25"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "accessible-pygments" },
{ name = "beautifulsoup4" },
{ name = "pygments" },
{ name = "sphinx" },
{ name = "sphinx-basic-ng" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4e/29/ff3b83a1ffce74676043ab3e7540d398e0b1ce7660917a00d7c4958b93da/furo-2025.9.25.tar.gz", hash = "sha256:3eac05582768fdbbc2bdfa1cdbcdd5d33cfc8b4bd2051729ff4e026a1d7e0a98", size = 1662007, upload-time = "2025-09-25T21:37:19.221Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/69/964b55f389c289e16ba2a5dfe587c3c462aac09e24123f09ddf703889584/furo-2025.9.25-py3-none-any.whl", hash = "sha256:2937f68e823b8e37b410c972c371bc2b1d88026709534927158e0cb3fac95afe", size = 340409, upload-time = "2025-09-25T21:37:17.244Z" },
]
[[package]]
name = "idna"
version = "3.10"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
]
[[package]]
name = "imagesize"
version = "1.4.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a7/84/62473fb57d61e31fef6e36d64a179c8781605429fd927b5dd608c997be31/imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a", size = 1280026, upload-time = "2022-07-01T12:21:05.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ff/62/85c4c919272577931d407be5ba5d71c20f0b616d31a0befe0ae45bb79abd/imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", size = 8769, upload-time = "2022-07-01T12:21:02.467Z" },
]
[[package]]
name = "iniconfig"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "markdown-it-py"
version = "3.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274, upload-time = "2024-10-18T15:21:13.777Z" },
{ url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348, upload-time = "2024-10-18T15:21:14.822Z" },
{ url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149, upload-time = "2024-10-18T15:21:15.642Z" },
{ url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118, upload-time = "2024-10-18T15:21:17.133Z" },
{ url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993, upload-time = "2024-10-18T15:21:18.064Z" },
{ url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178, upload-time = "2024-10-18T15:21:18.859Z" },
{ url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319, upload-time = "2024-10-18T15:21:19.671Z" },
{ url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352, upload-time = "2024-10-18T15:21:20.971Z" },
{ url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097, upload-time = "2024-10-18T15:21:22.646Z" },
{ url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601, upload-time = "2024-10-18T15:21:23.499Z" },
{ url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" },
{ url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" },
{ url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" },
{ url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" },
{ url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" },
{ url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" },
{ url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" },
{ url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" },
{ url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" },
{ url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" },
{ url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" },
{ url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" },
{ url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" },
{ url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" },
{ url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" },
{ url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" },
{ url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" },
{ url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" },
{ url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" },
{ url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" },
]
[[package]]
name = "mdit-py-plugins"
version = "0.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655, upload-time = "2025-08-11T07:25:49.083Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f", size = 57205, upload-time = "2025-08-11T07:25:47.597Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "myst-parser"
version = "4.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "docutils" },
{ name = "jinja2" },
{ name = "markdown-it-py" },
{ name = "mdit-py-plugins" },
{ name = "pyyaml" },
{ name = "sphinx" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/a5/9626ba4f73555b3735ad86247a8077d4603aa8628537687c839ab08bfe44/myst_parser-4.0.1.tar.gz", hash = "sha256:5cfea715e4f3574138aecbf7d54132296bfd72bb614d31168f48c477a830a7c4", size = 93985, upload-time = "2025-02-12T10:53:03.833Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5f/df/76d0321c3797b54b60fef9ec3bd6f4cfd124b9e422182156a1dd418722cf/myst_parser-4.0.1-py3-none-any.whl", hash = "sha256:9134e88959ec3b5780aedf8a99680ea242869d012e8821db3126d427edc9c95d", size = 84579, upload-time = "2025-02-12T10:53:02.078Z" },
]
[[package]]
name = "packaging"
version = "25.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pytest"
version = "8.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
]
[[package]]
name = "pyxdg"
version = "0.28"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/25/7998cd2dec731acbd438fbf91bc619603fc5188de0a9a17699a781840452/pyxdg-0.28.tar.gz", hash = "sha256:3267bb3074e934df202af2ee0868575484108581e6f3cb006af1da35395e88b4", size = 77776, upload-time = "2022-06-05T11:35:01Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/8d/cf41b66a8110670e3ad03dab9b759704eeed07fa96e90fdc0357b2ba70e2/pyxdg-0.28-py2.py3-none-any.whl", hash = "sha256:bdaf595999a0178ecea4052b7f4195569c1ff4d344567bccdc12dfdf02d545ab", size = 49520, upload-time = "2022-06-05T11:34:58.832Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "requests"
version = "2.32.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
]
[[package]]
name = "roman-numerals-py"
version = "3.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/30/76/48fd56d17c5bdbdf65609abbc67288728a98ed4c02919428d4f52d23b24b/roman_numerals_py-3.1.0.tar.gz", hash = "sha256:be4bf804f083a4ce001b5eb7e3c0862479d10f94c936f6c4e5f250aa5ff5bd2d", size = 9017, upload-time = "2025-02-22T07:34:54.333Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/53/97/d2cbbaa10c9b826af0e10fdf836e1bf344d9f0abb873ebc34d1f49642d3f/roman_numerals_py-3.1.0-py3-none-any.whl", hash = "sha256:9da2ad2fb670bcf24e81070ceb3be72f6c11c440d73bd579fbeca1e9f330954c", size = 7742, upload-time = "2025-02-22T07:34:52.422Z" },
]
[[package]]
name = "setuptools"
version = "80.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" },
]
[[package]]
name = "snowballstemmer"
version = "3.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/75/a7/9810d872919697c9d01295633f5d574fb416d47e535f258272ca1f01f447/snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895", size = 105575, upload-time = "2025-05-09T16:34:51.843Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274, upload-time = "2025-05-09T16:34:50.371Z" },
]
[[package]]
name = "soupsieve"
version = "2.8"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6d/e6/21ccce3262dd4889aa3332e5a119a3491a95e8f60939870a3a035aabac0d/soupsieve-2.8.tar.gz", hash = "sha256:e2dd4a40a628cb5f28f6d4b0db8800b8f581b65bb380b97de22ba5ca8d72572f", size = 103472, upload-time = "2025-08-27T15:39:51.78Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/a0/bb38d3b76b8cae341dad93a2dd83ab7462e6dbcdd84d43f54ee60a8dc167/soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c", size = 36679, upload-time = "2025-08-27T15:39:50.179Z" },
]
[[package]]
name = "sphinx"
version = "8.2.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "alabaster" },
{ name = "babel" },
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "docutils" },
{ name = "imagesize" },
{ name = "jinja2" },
{ name = "packaging" },
{ name = "pygments" },
{ name = "requests" },
{ name = "roman-numerals-py" },
{ name = "snowballstemmer" },
{ name = "sphinxcontrib-applehelp" },
{ name = "sphinxcontrib-devhelp" },
{ name = "sphinxcontrib-htmlhelp" },
{ name = "sphinxcontrib-jsmath" },
{ name = "sphinxcontrib-qthelp" },
{ name = "sphinxcontrib-serializinghtml" },
]
sdist = { url = "https://files.pythonhosted.org/packages/38/ad/4360e50ed56cb483667b8e6dadf2d3fda62359593faabbe749a27c4eaca6/sphinx-8.2.3.tar.gz", hash = "sha256:398ad29dee7f63a75888314e9424d40f52ce5a6a87ae88e7071e80af296ec348", size = 8321876, upload-time = "2025-03-02T22:31:59.658Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/31/53/136e9eca6e0b9dc0e1962e2c908fbea2e5ac000c2a2fbd9a35797958c48b/sphinx-8.2.3-py3-none-any.whl", hash = "sha256:4405915165f13521d875a8c29c8970800a0141c14cc5416a38feca4ea5d9b9c3", size = 3589741, upload-time = "2025-03-02T22:31:56.836Z" },
]
[[package]]
name = "sphinx-autodoc-typehints"
version = "3.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "sphinx" },
]
sdist = { url = "https://files.pythonhosted.org/packages/93/68/a388a9b8f066cd865d9daa65af589d097efbfab9a8c302d2cb2daa43b52e/sphinx_autodoc_typehints-3.2.0.tar.gz", hash = "sha256:107ac98bc8b4837202c88c0736d59d6da44076e65a0d7d7d543a78631f662a9b", size = 36724, upload-time = "2025-04-25T16:53:25.872Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f7/c7/8aab362e86cbf887e58be749a78d20ad743e1eb2c73c2b13d4761f39a104/sphinx_autodoc_typehints-3.2.0-py3-none-any.whl", hash = "sha256:884b39be23b1d884dcc825d4680c9c6357a476936e3b381a67ae80091984eb49", size = 20563, upload-time = "2025-04-25T16:53:24.492Z" },
]
[[package]]
name = "sphinx-basic-ng"
version = "1.0.0b2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "sphinx" },
]
sdist = { url = "https://files.pythonhosted.org/packages/98/0b/a866924ded68efec7a1759587a4e478aec7559d8165fac8b2ad1c0e774d6/sphinx_basic_ng-1.0.0b2.tar.gz", hash = "sha256:9ec55a47c90c8c002b5960c57492ec3021f5193cb26cebc2dc4ea226848651c9", size = 20736, upload-time = "2023-07-08T18:40:54.166Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3c/dd/018ce05c532a22007ac58d4f45232514cd9d6dd0ee1dc374e309db830983/sphinx_basic_ng-1.0.0b2-py3-none-any.whl", hash = "sha256:eb09aedbabfb650607e9b4b68c9d240b90b1e1be221d6ad71d61c52e29f7932b", size = 22496, upload-time = "2023-07-08T18:40:52.659Z" },
]
[[package]]
name = "sphinx-togglebutton"
version = "0.3.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "docutils" },
{ name = "setuptools" },
{ name = "sphinx" },
{ name = "wheel" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f0/df/d151dfbbe588116e450ca7e898750cb218dca6b2e557ced8de6f9bd7242b/sphinx-togglebutton-0.3.2.tar.gz", hash = "sha256:ab0c8b366427b01e4c89802d5d078472c427fa6e9d12d521c34fa0442559dc7a", size = 8324, upload-time = "2022-07-15T12:08:50.286Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/18/267ce39f29d26cdc7177231428ba823fe5ca94db8c56d1bed69033b364c8/sphinx_togglebutton-0.3.2-py3-none-any.whl", hash = "sha256:9647ba7874b7d1e2d43413d8497153a85edc6ac95a3fea9a75ef9c1e08aaae2b", size = 8249, upload-time = "2022-07-15T12:08:48.8Z" },
]
[[package]]
name = "sphinxcontrib-applehelp"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" },
]
[[package]]
name = "sphinxcontrib-devhelp"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" },
]
[[package]]
name = "sphinxcontrib-htmlhelp"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" },
]
[[package]]
name = "sphinxcontrib-jsmath"
version = "1.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" },
]
[[package]]
name = "sphinxcontrib-qthelp"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" },
]
[[package]]
name = "sphinxcontrib-serializinghtml"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" },
]
[[package]]
name = "symconf"
version = "0.8.3"
source = { editable = "." }
dependencies = [
{ name = "colorama" },
{ name = "pyxdg" },
]
[package.optional-dependencies]
dev = [
{ name = "pytest" },
]
doc = [
{ name = "furo" },
{ name = "myst-parser" },
{ name = "sphinx" },
{ name = "sphinx-autodoc-typehints" },
{ name = "sphinx-togglebutton" },
]
[package.metadata]
requires-dist = [
{ name = "colorama" },
{ name = "furo", marker = "extra == 'doc'" },
{ name = "myst-parser", marker = "extra == 'doc'" },
{ name = "pytest", marker = "extra == 'dev'" },
{ name = "pyxdg" },
{ name = "sphinx", marker = "extra == 'doc'" },
{ name = "sphinx-autodoc-typehints", marker = "extra == 'doc'" },
{ name = "sphinx-togglebutton", marker = "extra == 'doc'" },
]
provides-extras = ["doc", "dev"]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "urllib3"
version = "2.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
]
[[package]]
name = "wheel"
version = "0.45.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8a/98/2d9906746cdc6a6ef809ae6338005b3f21bb568bea3165cfc6a243fdc25c/wheel-0.45.1.tar.gz", hash = "sha256:661e1abd9198507b1409a20c02106d9670b2576e916d58f520316666abca6729", size = 107545, upload-time = "2024-11-23T00:18:23.513Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/2c/87f3254fd8ffd29e4c02732eee68a83a1d3c346ae39bc6822dcbcb697f2b/wheel-0.45.1-py3-none-any.whl", hash = "sha256:708e7481cc80179af0e556bbf0cc00b8444c7321e2700b8d8580231d13017248", size = 72494, upload-time = "2024-11-23T00:18:21.207Z" },
]