diff options
Diffstat (limited to 'dots.py')
-rwxr-xr-x | dots.py | 223 |
1 files changed, 109 insertions, 114 deletions
@@ -2,7 +2,7 @@ from dataclasses import dataclass from pathlib import Path -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Any import argparse import json import logging @@ -11,24 +11,26 @@ import subprocess import sys import shutil from concurrent.futures import Executor, ThreadPoolExecutor, as_completed +from functools import reduce import jinja2 +from kawaii_logger import setup_logger @dataclass(frozen=True) class Config: - SCRIPT_DIR: Path = Path(__file__).parent / "home/scripts" - COMPILED_DIR: Path = Path(".compiled_dotfiles") - CONTEXTS: Path = Path("context.json") - DEFAULT_TARGET_DIR: Path = Path.home() - DEFAULT_SOURCE_DIR: Path = Path(".") - MAX_WORKERS: int = (os.cpu_count() or 1) * 2 + script_dir: Path = Path(__file__).parent / "home/scripts" + compiled_dir: Path = Path(".compiled_dotfiles") + contexts: Path = Path("context.json") + default_target_dir: Path = Path.home() + default_source_dir: Path = Path(".") + max_workers: int = (os.cpu_count() or 1) * 2 @dataclass(frozen=True) class Environment: platform: str - device_name: str - context: Dict + system_name: str + context: Dict[str, Any] verbose: bool logger: logging.Logger @@ -38,134 +40,129 @@ def main() -> None: env = initialize_environment(args.verbose) if not (args.compile or args.stow or args.clean): - env.logger.error("At least one of --compile, --stow, or --clean must be specified") + env.logger.error("you gotta specify at least one action nya~ (⁎⁍̴̆‾⁍̴̆⁎)") sys.exit(1) if args.compile: - env.logger.debug(f"Compiling {args.source} -> {args.comp}") - if not compile_dotfiles(args.source, args.comp, env): - env.logger.error("Failed to compile dotfiles") + env.logger.info(f"compiling {args.source} to {args.comp} ✨✧˖°") + if not compile_dotfiles(Path(args.source), Path(args.comp), env): + env.logger.error("uh oh! failed to compile dotfiles (ɐ•゚́•̀ɐ)") sys.exit(1) if args.stow: - env.logger.debug(f"Stowing {(args.target)}") - if not stow_dotfiles(args.comp, args.target, env, clean=False): - env.logger.error("Failed to stow dotfiles") + if not stow_dotfiles(Path(args.comp), Path(args.target), env, clean=False): + env.logger.error("failed to stow dotfiles... nyaaa (╥゚╥)") sys.exit(1) if args.clean: - env.logger.debug(f"Cleaning dotfiles from {(args.target)}") - if not stow_dotfiles(args.comp, args.target, env, clean=True): - env.logger.error("Failed to clean dotfiles") + env.logger.info(f"cleaning dotfiles from {args.target} (⌟‾╥ ‾╥)°") + if not stow_dotfiles(Path(args.comp), Path(args.target), env, clean=True): + env.logger.error("couldn’t clean dotfiles... sobs (ɐ•゚́•̀ɐ)") sys.exit(1) - env.logger.info("Done!") + env.logger.info("yay~ all done!! ₕᵒ. .ᵒₕ♡") sys.exit(0) def initialize_environment(verbose: bool) -> Environment: - logger = setup_logging(verbose) + logger = setup_logger(verbose) platform = get_platform() - device_name = get_device_name() + system_name = get_system_name() context = { - **load_context(platform, device_name, Config.CONTEXTS), - "platform": platform, - "device_name": device_name, + **merge_dicts( + load_context(platform, system_name, Config.contexts, logger), + {"platform": platform, "system_name": system_name}, + ) } - - return Environment(platform, device_name, context, verbose, logger) + return Environment(platform, system_name, context, verbose, logger) -def copy_with_templates_rendered(executor: Executor, source: Path, destination: Path, env: Environment) -> bool: +def copy_with_templates_rendered( + executor: Executor, source: Path, destination: Path, env: Environment +) -> bool: shutil.copytree(source, destination, dirs_exist_ok=True) - env.logger.debug(f"copytree {source} -> {destination}") + env.logger.debug(f"copied {source} to {destination} ₰˜⋉♡") templates = [t for t in destination.glob("**/*.j2") if t.is_file()] if not templates: - env.logger.debug(f"{source} has no templates") + env.logger.debug(f"no templates to render in {source} (•ᴗ•)⁎") return True for template in templates: - env.logger.debug(f"submitting work to render {source} . {templates}") + env.logger.debug(f"submitting template render for {template} ₰˜൨൨") executor.submit(replace_with_rendered_template, template, env) return True + def replace_with_rendered_template(template: Path, env: Environment) -> bool: - env.logger.debug(f"rendering {template}") + env.logger.debug(f"rendering template {template} ✧*ฺ") jinja_env = jinja2.Environment( - undefined=jinja2.StrictUndefined, trim_blocks=True, lstrip_blocks=True + loader=jinja2.BaseLoader, + undefined=jinja2.StrictUndefined, + trim_blocks=True, + lstrip_blocks=True, ) + rendered = render(template, jinja_env, env) with open(template, "w") as t: - t.write(render(template, jinja_env, env) or "") - env.logger.debug(f"stripping template suffix of {template}") + t.write(rendered) + env.logger.debug(f"removing .j2 suffix from {template.absolute()} ✧˖°") template.rename(template.with_suffix("")) return True - -def compile_dotfiles(source_dir: Path, target_dir: Path, env: Environment): +def compile_dotfiles(source_dir: Path, target_dir: Path, env: Environment) -> bool: target_dir.mkdir(exist_ok=True, parents=True) - with ThreadPoolExecutor(max_workers=Config.MAX_WORKERS) as executor: - dotfile_directories = [ - dir for dir in source_dir.iterdir() if dir.is_dir() and "." not in dir.name - ] - env.logger.debug(f"compiling dotfile directories {dotfile_directories}") + with ThreadPoolExecutor(max_workers=Config.max_workers) as executor: + dotfile_dirs = list_dotfiles(source_dir) + env.logger.debug(f"found dotfile dirs: {dotfile_dirs}") + futures = [ executor.submit( - copy_with_templates_rendered, executor, dotfiles, target_dir / dotfiles.name, env + copy_with_templates_rendered, + executor, + d, + target_dir / d.name, + env, ) - for dotfiles in dotfile_directories + for d in dotfile_dirs ] - env.logger.debug(f"constructed {len(futures)} tasks") - return sum(1 for future in as_completed(futures) if future.result()) == len( - futures - ) - return False + env.logger.info(f"submitted {len(futures)} tasks to executor ₰˜.༄") + + return sum(1 for f in as_completed(futures) if f.result()) == len(futures) -def render( - source: Path, - jinja_env: jinja2.Environment, - env: Environment, -) -> Optional[str]: +def render(source: Path, jinja_env: jinja2.Environment, env: Environment) -> Optional[str]: try: - env.logger.debug(f"Reading {source}") with open(source, "r") as f: - template_content = f.read() - env.logger.debug(f"Compiling template {source}") - template = jinja_env.from_string(template_content) + content = f.read() + env.logger.debug(f"reading template {source} ✿.。.:・") + template = jinja_env.from_string(content) + env.logger.debug(f"rendered template from {source} ~ nyaaa :3") return template.render(**env.context) except Exception as e: - env.logger.error(f"Error rendering template {source}: {e}") + env.logger.error(f"couldn’t render {source}: {e} (;⌓̀_⌓́)") return None -def stow_package_task( - stow_dir: Path, package: Path, target_dir: Path, env: Environment -) -> bool: - try: - return True - except subprocess.SubprocessError as e: - env.logger.error(f"Error stowing package {package.name}: {e}") - return False - - def stow_dotfiles(source_dir: Path, target_dir: Path, env: Environment, clean: bool = False) -> bool: if not stow_installed(): + env.logger.error("stow not installed (╥゚╥)") return False - packages = [d for d in source_dir.iterdir() if d.is_dir()] - stow_op = ["-D"] if clean else ["--no-folding"] + packages = list_dotfiles(source_dir) + stow_cmd = ["-D"] if clean else ["--no-folding"] for pkg in packages: - run_shell_command(["stow", *stow_op, pkg.name]) + env.logger.info(f"running stow for {pkg.name} ₰˜݆༿") + run_shell_command(["stow", "-d", source_dir, "-t", target_dir, *stow_cmd, pkg.name]) + return True def stow_installed() -> bool: - return "" != run_shell_command(["stow", "--version"]) + return run_shell_command(["stow", "--version"]) != "" def run_shell_command(cmd: List[str]) -> str: @@ -174,61 +171,59 @@ def run_shell_command(cmd: List[str]) -> str: def get_platform() -> str: - return run_shell_command([str(Config.SCRIPT_DIR / "platform.sh")]) + return run_shell_command([str(Config.script_dir / "platform.sh")]) -def get_device_name() -> str: - return run_shell_command([str(Config.SCRIPT_DIR / "system_name.sh")]) +def get_system_name() -> str: + os.environ["PLATFORM"] = get_platform() + return run_shell_command([str(Config.script_dir / "system_name.sh")]) +def list_dotfiles(p: Path) -> list[Path]: + denylist = [".", "__"] + return [d for d in p.iterdir() if d.is_dir() and all(y not in d.name for y in denylist)] -def load_context(platform: str, device_name: str, context_file: Path) -> Dict: +def load_context(platform: str, system_name: str, context_file: Path, logger: logging.Logger) -> Dict[str, Any]: try: + logger.info(f"reading context file: {context_file} ✧*:。゚✧") with open(context_file) as f: contexts = json.load(f) - if platform not in contexts: - return {} + global_config = contexts.get("_global", {}) + platform_defaults = contexts.get(platform, {}).get("_default", {}) + defaults = merge_dicts(global_config, platform_defaults) - return contexts[platform].get( - device_name, contexts[platform].get("default", {}) - ) - except (FileNotFoundError, json.JSONDecodeError): + system_config = contexts.get(platform, {}).get(system_name, {}) + if not system_config: + logger.warning(f"couldn’t find system-specific config for {platform}.{system_name} (ɐ•゚́•̀ɐ)") + + return merge_dicts(defaults, system_config) + except (FileNotFoundError, json.JSONDecodeError) as e: + logger.error(f"error loading context: {e} ⋆ฺ°☁。⋆ฺ °★ °。") return {} -def parse_arguments() -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Dotfiles manager") - parser.add_argument("--compile", action="store_true", help="Compile dotfiles") - parser.add_argument("--stow", action="store_true", help="Stow compiled dotfiles") - parser.add_argument("--clean", action="store_true", help="Clean (remove) stowed dotfiles") - parser.add_argument( - "--source", - help=f"Target directory for dotfiles (default: {Config.DEFAULT_SOURCE_DIR})", - default=Config.DEFAULT_SOURCE_DIR, - ) - parser.add_argument( - "--comp", - help=f"Target directory for compiled templates (default: {Config.COMPILED_DIR})", - default=Config.COMPILED_DIR, - ) - parser.add_argument( - "--target", - help=f"Target directory for stow (default: {Config.DEFAULT_TARGET_DIR})", - default=Config.DEFAULT_TARGET_DIR, - ) - parser.add_argument( - "--verbose", "-v", action="store_true", help="Enable verbose logging" - ) - return parser.parse_args() +def merge_dicts(*dicts: Dict[str, Any]) -> Dict[str, Any]: + def merge(a: Dict[str, Any], b: Dict[str, Any]) -> Dict[str, Any]: + out = dict(a) + for k, v in b.items(): + if k in out and isinstance(out[k], dict) and isinstance(v, dict): + out[k] = merge(out[k], v) + else: + out[k] = v + return out + return reduce(merge, dicts, {}) -def setup_logging(verbose: bool) -> logging.Logger: - logging.basicConfig( - level=logging.DEBUG if verbose else logging.INFO, - format="%(asctime)s - %(levelname)s - %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", - ) - return logging.getLogger("dotfiles") +def parse_arguments() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="cute dotfiles manager ✧˖°") + parser.add_argument("--compile", action="store_true", help="compile dotfiles ✨") + parser.add_argument("--stow", action="store_true", help="stow compiled dotfiles ♡") + parser.add_argument("--clean", action="store_true", help="clean stowed dotfiles ˚°") + parser.add_argument("--source", default=Config.default_source_dir, help="source dir for dotfiles") + parser.add_argument("--comp", default=Config.compiled_dir, help="compiled template output dir") + parser.add_argument("--target", default=Config.default_target_dir, help="stow target directory") + parser.add_argument("--verbose", "-v", action="store_true", help="enable verbose logging ✧*ฺ", default=False) + return parser.parse_args() if __name__ == "__main__": |