diff --git a/.gitignore b/.gitignore index 2511f64..1df09f3 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ __pycache__/ .ipynb_checkpoints/ .pytest_cache/ .python-version +.venv/ # vendor and build files dist/ diff --git a/README.md b/README.md index 2312c33..7072094 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ for managing Python packages meant to be used as CLI programs. With `uv` on your you can install with ```sh -uv too install symconf +uv tool install symconf ``` Alternatively, you can use `pipx` to similar effect: diff --git a/docs/conf.py b/docs/conf.py index 9ce7475..a83e3bd 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -3,43 +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 = 'symconf' -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", - "sphinx.ext.autosummary", # enables a directive to be specified manually that gathers - # module/object summary details in a table - "sphinx.ext.viewcode", # allow viewing source in the HTML pages - "myst_parser", # only really applies to manual docs; docstrings still need RST-like - "sphinx.ext.napoleon", # enables Google-style docstring formats - "sphinx_autodoc_typehints", # external extension that allows arg types to be inferred by type hints + # 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 # include __init__ definitions in autodoc autodoc_default_options = { - 'special-members': '__init__', + "special-members": "__init__", } -templates_path = ['_templates'] -exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] +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'], -#} - +# } diff --git a/pyproject.toml b/pyproject.toml index 89860fe..d2fce3f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,27 +1,26 @@ [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.2" 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", @@ -32,15 +31,16 @@ dependencies = [ symconf = "symconf.__main__:main" [project.optional-dependencies] -tests = ["pytest"] -docs = [ +doc = [ "sphinx", "sphinx-togglebutton", "sphinx-autodoc-typehints", "furo", "myst-parser", ] -build = ["build", "twine"] +dev = [ + "pytest" +] [project.urls] Homepage = "https://doc.olog.io/symconf" @@ -50,3 +50,23 @@ 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 diff --git a/symconf/__init__.py b/symconf/__init__.py index 4adc8e3..5b7c964 100644 --- a/symconf/__init__.py +++ b/symconf/__init__.py @@ -1,16 +1,10 @@ -from symconf.runner import Runner -from symconf.reader import DictReader +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 -from symconf import config -from symconf import matching -from symconf import reader -from symconf import template -from symconf import util - -from importlib.metadata import version - - -__version__ = version('symconf') +__version__ = version("symconf") diff --git a/symconf/__main__.py b/symconf/__main__.py index 72fa083..e8c7fd6 100644 --- a/symconf/__main__.py +++ b/symconf/__main__.py @@ -1,179 +1,214 @@ -import argparse -from importlib.metadata import version +from argparse import Namespace, ArgumentParser from symconf import util, __version__ from symconf.config import ConfigManager -def add_install_subparser(subparsers): - def install_apps(args): +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.' + "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' + "-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): - def update_apps(args): + +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.' + "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' + "-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): - def configure_apps(args): + +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 + **args.template_vars, ) parser = subparsers.add_parser( - 'config', - description='Set config files for registered applications.' + "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' + "-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."' + "-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' + "-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 = {}, + "-T", + "--template-vars", + required=False, + nargs="+", + default={}, action=util.KVPair, - help='Groups to use when populating templates, in the form group=value' + help=( + "Groups to use when populating templates, in the form group=value" + ), ) parser.set_defaults(func=configure_apps) -def add_generate_subparser(subparsers): - def generate_apps(args): + +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 + **args.template_vars, ) parser = subparsers.add_parser( - 'generate', - description='Generate all template config files for specified apps' + "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' + "-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' + "-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."' + "-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' + "-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' + "-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 = argparse.ArgumentParser( - 'symconf', - description='Manage application configuration with symlinks.' +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' + "-c", + "--config-dir", + default=util.xdg_config_path(), + type=util.absolute_path, + help="Path to config directory", ) parser.add_argument( - '-v', '--version', - action='version', + "-v", + "--version", + action="version", version=__version__, - help = 'Print symconf version' + help="Print symconf version", ) # add subparsers -subparsers = parser.add_subparsers(title='subcommand actions') +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(): +def main() -> None: args = parser.parse_args() - if 'func' in args: + if "func" in args: args.func(args) else: parser.print_help() -if __name__ == '__main__': + +if __name__ == "__main__": main() diff --git a/symconf/config.py b/symconf/config.py index 8b30ab5..6926222 100644 --- a/symconf/config.py +++ b/symconf/config.py @@ -1,37 +1,42 @@ -''' +""" Primary config management abstractions -The config map is a dict mapping from config file **path names** to their absolute -path locations. That is, +The config map is a dict mapping from config file **path names** to their +absolute path locations. That is, .. code-block:: sh - -> /apps///-. + + -> + /apps///-. 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 + palette1-light.conf.ini + -> + ~/.config/symconf/apps/user/palette1-light.conf.ini -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). -''' + palette2-dark.app.conf + -> + ~/.config/symconf/apps/generated/palette2-dark.app.conf + +This ensures we have unique config names pointing to appropriate locations +(which is mostly important when the same config file names are present across +``user`` and ``generated`` subdirectories; unique path names need to be +resolved to unique path locations). +""" -import os import sys import tomllib import subprocess from pathlib import Path -from colorama import Fore, Back, Style +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 @@ -40,10 +45,10 @@ from symconf.template import FileTemplate, TOMLTemplate class ConfigManager: def __init__( self, - config_dir=None, - disable_registry=False, - ): - ''' + config_dir: str | Path | None = None, + disable_registry: bool = False, + ) -> None: + """ Configuration manager class Parameters: @@ -53,14 +58,14 @@ class ConfigManager: 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: + 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.apps_dir = Path(self.config_dir, "apps") + self.group_dir = Path(self.config_dir, "groups") self.app_registry = {} self.matcher = Matcher() @@ -70,68 +75,74 @@ class ConfigManager: if not disable_registry: self._check_registry() - def _check_dirs(self): - ''' + 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). - ''' + ``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.' + f'Config directory "{self.config_dir}" must' + ' have an "apps/" subdirectory.' ) - def _check_registry(self): - ''' + def _check_registry(self) -> None: + """ Check the existence and format of the registry file ``/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. - ''' + 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') + registry_path = Path(self.config_dir, "app_registry.toml") if not registry_path.exists(): printc( - f'No registry file found at expected location "{registry_path}"', - Fore.YELLOW + f"No registry file found at expected" + f' location "{registry_path}"', + Fore.YELLOW, ) return - app_registry = tomllib.load(registry_path.open('rb')) + app_registry = tomllib.load(registry_path.open("rb")) - if 'app' not in app_registry: + if "app" not in app_registry: printc( - 'Registry file found but is either empty or incorrectly formatted (no "app" key).', - Fore.YELLOW + "Registry file found but is either empty or" + ' incorrectly formatted (no "app" key).', + Fore.YELLOW, ) - self.app_registry = app_registry.get('app', {}) + self.app_registry = app_registry.get("app", {}) - def _resolve_group(self, group, value='auto'): - ''' + 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). - ''' + 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': + if value == "auto": # look group up in app cache and set to current value - return 'any' + return "any" return value @@ -139,35 +150,37 @@ class ConfigManager: 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. + 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 - ''' + 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'Internal config path "{to_path}" doesn\'t exist, skipping' + 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 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; ' - f'please first manually remove this file so a symlink can be set.', + 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 @@ -176,7 +189,7 @@ class ConfigManager: self.symlink(from_path, to_path, user) links_succ.append((from_path, to_path)) except Exception as e: - reason = f'Symlink failed: {e}' + reason = f"Symlink failed: {e}" links_fail.append((from_path, to_path, reason)) # link report @@ -186,9 +199,10 @@ class ConfigManager: print( color_text("│", Fore.BLUE), color_text( - f' > linked {color_text(from_p,Style.BRIGHT)} -> {color_text(to_p,Style.BRIGHT)}', - Fore.GREEN - ) + 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: @@ -197,89 +211,88 @@ class ConfigManager: print( color_text("│", Fore.BLUE), - color_text( - f' > failed to link {from_p} -> {to_p}', - Fore.RED - ) + 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) + color_text(f" > {reason}", Fore.RED + Style.DIM), ) def _matching_template_groups( - self, - scheme = 'auto', - style = 'auto', - **kw_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 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 + 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