summaryrefslogtreecommitdiff
path: root/main.py
diff options
context:
space:
mode:
Diffstat (limited to 'main.py')
-rwxr-xr-xmain.py255
1 files changed, 255 insertions, 0 deletions
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()