From d098e94ad102da9d018acca72ca5a5c554d25a01 Mon Sep 17 00:00:00 2001 From: Elizabeth Date: Mon, 2 Jun 2025 13:11:10 -0700 Subject: Update paths n stuff --- main.py | 255 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100755 main.py (limited to 'main.py') diff --git a/main.py b/main.py new file mode 100755 index 0000000..34dbc04 --- /dev/null +++ b/main.py @@ -0,0 +1,255 @@ +#!/usr/bin/env python3 + +import sys + +sys.dont_write_bytecode = True + +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, List, Optional, Any +import argparse +import json +import logging +import os +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: + default_target_dir: Path = Path.home() + default_source_dir: Path = Path(__file__).parent / "dots" + default_compiled_dir: Path = Path("./.compiled_dotfiles/") + + script_dir: Path = Path("home/scripts") + contexts: Path = Path("contexts.json") + max_workers: int = (os.cpu_count() or 1) * 2 + + +@dataclass(frozen=True) +class Environment: + platform: str + system_name: str + context: Dict[str, Any] + logger: logging.Logger + + +def main() -> None: + args = parse_arguments() + env = initialize_environment(args) + + if not (args.compile or args.stow or args.clean): + env.logger.error("you gotta specify at least one action nya~ (⁎⁍̴̆‾⁍̴̆⁎)") + sys.exit(1) + + if args.compile: + 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: + 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.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("yay~ all done!! ₕᵒ. .ᵒₕ♡") + sys.exit(0) + + +def initialize_environment(args) -> Environment: + logger = setup_logger(args.verbose) + + scripts = args.source / Config.script_dir + platform = run_shell_command([str(scripts / "platform.sh")]) + os.environ["PLATFORM"] = platform + system_name = run_shell_command([str(scripts / "system_name.sh")]) + + context = load_context(platform, system_name, Config.contexts, logger) + return Environment(platform, system_name, context, logger) + + +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"copied {source} to {destination} ₰˜⋉♡") + + templates = [t for t in destination.glob("**/*.j2") if t.is_file()] + if not templates: + env.logger.debug(f"no templates to render in {source} (•ᴗ•)⁎") + return True + + for template in 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 {template} ✧*ฺ") + jinja_env = jinja2.Environment( + 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(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) -> bool: + target_dir.mkdir(exist_ok=True, parents=True) + + with ThreadPoolExecutor(max_workers=Config.max_workers) as executor: + dotfile_dirs = list_dotfile_stows(source_dir) + env.logger.debug(f"found dotfile dirs: {dotfile_dirs}") + + futures = [ + executor.submit( + copy_with_templates_rendered, + executor, + d, + target_dir / d.name, + env, + ) + for d in dotfile_dirs + ] + 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]: + try: + with open(source, "r") as f: + 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"couldn’t render {source}: {e} (;⌓̀_⌓́)") + return None + + +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 = list_dotfile_stows(source_dir) + stow_cmd = ["-D"] if clean else ["--no-folding"] + + for pkg in packages: + env.logger.info(f"running stow {stow_cmd} 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"]) != "" + + +def run_shell_command(cmd: List[str]) -> str: + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + return result.stdout.strip() + + +def list_dotfile_stows(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, 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) + + global_config = contexts.get("_global", {}) + platform_defaults = contexts.get(platform, {}).get("_default", {}) + defaults = merge_dicts(global_config, platform_defaults) + + 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 { + "platform": platform, + "system_name": system_name, + **merge_dicts(defaults, system_config) + } + except (FileNotFoundError, json.JSONDecodeError) as e: + logger.error(f"error loading context: {e} ⋆ฺ°☁。⋆ฺ °★ °。") + return {} + + +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 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=f"directory with stowable dotfiles. default '{Config.default_source_dir}'." + ) + parser.add_argument( + "--comp", default=Config.default_compiled_dir, help=f"compiled template output dir. default '{Config.default_compiled_dir}'. :3" + ) + parser.add_argument( + "--target", default=Config.default_target_dir, help=f"stow target directory. default '{Config.default_target_dir}'. -.-" + ) + parser.add_argument( + "--verbose", + "-v", + action="store_true", + help="enable verbose logging. default False. :D", + default=False, + ) + return parser.parse_args() + + +if __name__ == "__main__": + main() -- cgit v1.2.3-70-g09d2