diff options
author | Elizabeth <me@liz.coffee> | 2025-06-02 15:54:32 -0700 |
---|---|---|
committer | Elizabeth <me@liz.coffee> | 2025-06-02 15:54:32 -0700 |
commit | 03646d1e891271339ca256fadd15eaa8ac678911 (patch) | |
tree | 5df23a874fa1ae5c62dad318d9098225852f2137 | |
parent | d098e94ad102da9d018acca72ca5a5c554d25a01 (diff) | |
download | dotfiles-03646d1e891271339ca256fadd15eaa8ac678911.tar.gz dotfiles-03646d1e891271339ca256fadd15eaa8ac678911.zip |
Make it a python package
-rw-r--r-- | .gitignore | 1 | ||||
-rwxr-xr-x | dots_manager/.main.py | 328 | ||||
-rw-r--r-- | dots_manager/__init__.py | 1 | ||||
-rw-r--r-- | dots_manager/args.py | 43 | ||||
-rw-r--r-- | dots_manager/cli.py | 20 | ||||
-rw-r--r-- | dots_manager/config.py | 14 | ||||
-rw-r--r-- | dots_manager/env.py | 56 | ||||
-rw-r--r-- | dots_manager/kawaii_logger.py (renamed from kawaii_logger.py) | 11 | ||||
-rw-r--r-- | dots_manager/parallel.py | 21 | ||||
-rw-r--r-- | dots_manager/setup.py | 19 | ||||
-rw-r--r-- | dots_manager/shell.py | 14 | ||||
-rw-r--r-- | dots_manager/stow.py | 40 | ||||
-rw-r--r-- | dots_manager/template.py | 73 | ||||
-rw-r--r-- | dots_manager/utils.py | 25 | ||||
-rwxr-xr-x | main.py | 255 | ||||
-rw-r--r-- | pyproject.toml | 20 |
16 files changed, 677 insertions, 264 deletions
@@ -1,3 +1,4 @@ +*.egg-info .compiled_dotfiles/ .venv __pycache__ diff --git a/dots_manager/.main.py b/dots_manager/.main.py new file mode 100755 index 0000000..c430320 --- /dev/null +++ b/dots_manager/.main.py @@ -0,0 +1,328 @@ +#!/usr/bin/env python3 + +import sys + +sys.dont_write_bytecode = True + +from dataclasses import dataclass +from pathlib import Path +from typing import ( + Dict, + Generator, + List, + Optional, + Any, + Literal, + ParamSpec, + Tuple, + Callable, + TypeVar, +) +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 + +R = TypeVar("R") +T = TypeVar("T") +P = ParamSpec("P") + + +@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") + + template_extension: str = ".j2" + 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 env: + sys.exit(1) + if not any((args.compile, args.stow, args.clean)): + env.logger.error("you gotta specify at least one action nya~ (⁎⁍̴̆‾⁍̴̆⁎)") + sys.exit(1) + + if args.compile and 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 and not apply_stow_operation_to_packages( + Path(args.comp), Path(args.target), "--no-folding", env + ): + env.logger.error("failed to stow dotfiles... nyaaa (╥゚╥)") + sys.exit(1) + + if args.clean and not apply_stow_operation_to_packages( + Path(args.comp), Path(args.target), "-D", env + ): + 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")], logger) + if not platform: + raise ValueError("failed to determine platform... ") + + os.environ["PLATFORM"] = platform + system_name = run_shell_command([str(scripts / "system_name.sh")], logger) + if not system_name: + raise ValueError("failed to determine system name... ") + + context = load_context(platform, system_name, Config.contexts, logger) + return Environment(platform, system_name, context, logger) + + +def render_template_to( + template: Path, destination: Path, env: Environment +) -> Optional[Path]: + env.logger.debug(f"rendering template {template} to {destination} ✧*ฺ") + if not is_template(template): + env.logger.error(f"template {template} is nyot a vawid tempwate D:") + return None + + 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) + if not rendered: + env.logger.error(f"failed to render template {template}") + return None + + env.logger.debug(f"rendered {template} successfully~ :D ✧*ฺ") + destination.write_text(rendered) + return destination + + +def compile_dotfiles(source: Path, output: Path, env: Environment) -> bool: + env.logger.info(f"compiling {source} to {output} ✨") + output.mkdir(exist_ok=True, parents=True) + shutil.copytree(source, output, dirs_exist_ok=True) + env.logger.debug(f"copied {source} to {output} ₰˜⋉♡") + + tasks = parallelize( + lambda template: is_some( + render_template_to, + template, + template.with_suffix(""), + env, + ) + and (template.unlink() is None), + find_templates_under(output), + env, + ) + return all(tasks) + + +def render( + source: Path, jinja_env: jinja2.Environment, env: Environment +) -> Optional[str]: + try: + env.logger.debug(f"rendering template {source} ✿.。.:・") + return jinja_env.from_string(source.read_text()).render(**env.context) + except Exception as e: + env.logger.error(f"couldn’t render {source}: {e} (;⌓̀_⌓́)") + return None + + +def apply_stow_operation_to_packages( + packages: Path, + target: Path, + stow_op: Literal["-D", "--no-folding"], + env: Environment, +) -> bool: + if not run_shell_command(["stow", "--version"], env.logger): + env.logger.error("stow not installed D:") + return False + package_command = ["stow", "-d", str(packages), "-t", str(target), stow_op] + + _packages = list_stowable_packages(packages) + env.logger.debug(f"found dotfile packages: {_packages}") + commands = [package_command + [package.name] for package in _packages] + + env.logger.debug(f"stowing packages: {commands}") + results = parallelize( + lambda command: is_some(run_shell_command, command, env.logger), + commands, + env, + ) + return len(commands) == sum(1 if x else 0 for x in results) + + +def parallelize( + worker: Callable[[T], R], + items: List[T], + env: Environment, + executor: Optional[Executor] = None, +) -> List[R]: + if executor is None: + executor = ThreadPoolExecutor(max_workers=Config.max_workers) + with executor as executor: + futures = [ + executor.submit( + worker, + item, + ) + for item in items + ] + env.logger.info(f"submitted {len(futures)} tasks to executor ₰˜.༄") + return [f.result() for f in as_completed(futures)] + + +def run_shell_command(cmd: List[str], logger: logging.Logger) -> Optional[str]: + logger.debug(f"running command: {cmd}") + try: + return subprocess.run( + cmd, capture_output=True, text=True, check=True + ).stdout.strip() + except subprocess.CalledProcessError as e: + logger.error(f"command failed: {cmd}, {e.stderr}") + return None + + +def is_template(path: Path) -> bool: + return path.suffix == Config.template_extension + + +def find_templates_under(destination: Path) -> List[Path]: + return [ + t + for t in destination.glob("**/*" + Config.template_extension) + if t.is_file() and is_template(t) + ] + + +def list_stowable_packages(packages: Path) -> List[Path]: + denylist = [".", "__"] + return [ + d + for d in packages.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 is_some( + callable: Callable[P, Optional[T]], *args: P.args, **kwargs: P.kwargs +) -> Tuple[bool, Optional[T]]: + result = callable(*args, **kwargs) + return result is not None, result + + +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="action: compile dotfiles" + ) + parser.add_argument( + "--source", + type=Path, + default=Config.default_source_dir, + help=f"directory with stowable dotfiles. default '{Config.default_source_dir}'. :)", + ) + parser.add_argument( + "--comp", + type=Path, + default=Config.default_compiled_dir, + help=f"compiled template output dir. default '{Config.default_compiled_dir}'. :3", + ) + + parser.add_argument( + "--stow", action="store_true", help="action: stow compiled dotfiles" + ) + parser.add_argument( + "--clean", action="store_true", help="action: clean stowed dotfiles" + ) + parser.add_argument( + "--target", + type=Path, + 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() diff --git a/dots_manager/__init__.py b/dots_manager/__init__.py new file mode 100644 index 0000000..792d600 --- /dev/null +++ b/dots_manager/__init__.py @@ -0,0 +1 @@ +# diff --git a/dots_manager/args.py b/dots_manager/args.py new file mode 100644 index 0000000..232c402 --- /dev/null +++ b/dots_manager/args.py @@ -0,0 +1,43 @@ +import argparse +from pathlib import Path +from .config import Config + + +def parse_arguments() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="cute dotfiles manager ✧˖°") + + parser.add_argument( + "--compile", action="store_true", help="action: compile dotfiles" + ) + parser.add_argument( + "--source", + type=Path, + default=Config.default_source_dir, + help=f"default: '{Config.default_source_dir}'", + ) + parser.add_argument( + "--comp", + type=Path, + default=Config.default_compiled_dir, + help=f"default: '{Config.default_compiled_dir}'", + ) + parser.add_argument( + "--stow", action="store_true", help="action: stow compiled dotfiles" + ) + parser.add_argument( + "--clean", action="store_true", help="action: clean stowed dotfiles" + ) + parser.add_argument( + "--target", + type=Path, + default=Config.default_target_dir, + help=f"default: '{Config.default_target_dir}'", + ) + parser.add_argument( + "--verbose", + "-v", + action="store_true", + default=False, + help="enable verbose logging", + ) + return parser.parse_args() diff --git a/dots_manager/cli.py b/dots_manager/cli.py new file mode 100644 index 0000000..4df416f --- /dev/null +++ b/dots_manager/cli.py @@ -0,0 +1,20 @@ +from .args import parse_arguments +from .env import create_environment +from .template import compile_dotfiles +from .stow import apply_stow_operation_to_packages + +import sys + +sys.dont_write_bytecode = True + + +def main(): + args = parse_arguments() + env = create_environment(args) + + if args.clean: + apply_stow_operation_to_packages(args.comp, args.target, "-D", env) + if args.compile: + compile_dotfiles(args.source, args.comp, env) + if args.stow: + apply_stow_operation_to_packages(args.comp, args.target, "--no-folding", env) diff --git a/dots_manager/config.py b/dots_manager/config.py new file mode 100644 index 0000000..d6194f6 --- /dev/null +++ b/dots_manager/config.py @@ -0,0 +1,14 @@ +from dataclasses import dataclass +from pathlib import Path +import os + + +@dataclass(frozen=True) +class Config: + default_target_dir: Path = Path.home() + default_source_dir: Path = Path(__file__).parent.parent / "dots" + default_compiled_dir: Path = Path("./.compiled_dotfiles/") + script_dir: Path = Path("home/scripts") + contexts: Path = Path("contexts.json") + template_extension: str = ".j2" + max_workers: int = (os.cpu_count() or 1) * 2 diff --git a/dots_manager/env.py b/dots_manager/env.py new file mode 100644 index 0000000..514624b --- /dev/null +++ b/dots_manager/env.py @@ -0,0 +1,56 @@ +import os +import json +import logging +from pathlib import Path +from dataclasses import dataclass +from typing import Dict, Any +from .config import Config +from .shell import run_shell_command +from .utils import merge_dicts +from .kawaii_logger import setup_logger + + +@dataclass(frozen=True) +class Environment: + platform: str + system_name: str + context: Dict[str, Any] + logger: logging.Logger + + +def initialize_environment(args) -> Environment: + logger = setup_logger(args.verbose) + scripts = args.source / Config.script_dir + platform = run_shell_command([str(scripts / "platform.sh")], logger) + if not platform: + raise ValueError("failed to determine platform... ") + os.environ["PLATFORM"] = platform + system_name = run_shell_command([str(scripts / "system_name.sh")], logger) + if not system_name: + raise ValueError("failed to determine system name... ") + context = load_context(platform, system_name, Config.contexts, logger) + return Environment(platform, system_name, context, logger) + + +def load_context(platform, system_name, context_file, logger): + 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 {} diff --git a/kawaii_logger.py b/dots_manager/kawaii_logger.py index 745e303..f82da23 100644 --- a/kawaii_logger.py +++ b/dots_manager/kawaii_logger.py @@ -33,23 +33,16 @@ class KawaiiFormatter(logging.Formatter): base_emotes = MOOD_EMOTICONS.get(level, []) mood_emotes = [] - msg_lower = record.msg.lower() - for suffix, emotes in MOOD_SUFFIXES.items(): - if suffix in msg_lower: - mood_emotes = emotes - record.msg = record.msg.replace(suffix, "") - break - emote_pool = mood_emotes if mood_emotes else base_emotes emote = random.choice(emote_pool) if emote_pool else "(・ω・)" - message = record.msg.lower().strip() + message = record.msg.strip() ts = self.formatTime(record, "%Y-%m-%d %H:%M:%S") lvl = record.levelname filename = record.filename lineno = str(record.lineno) - formatted = f"[{ts}] {color}[{lvl}]{RESET_COLOR} [{filename}:{lineno}] {color}{message}{RESET_COLOR} {emote}" + formatted = f"[{ts}] {color}[{lvl} {filename}:{lineno}]{RESET_COLOR} {color}{message}{RESET_COLOR} {emote}" return f"{formatted}{RESET_COLOR}" diff --git a/dots_manager/parallel.py b/dots_manager/parallel.py new file mode 100644 index 0000000..e50c5a8 --- /dev/null +++ b/dots_manager/parallel.py @@ -0,0 +1,21 @@ +from concurrent.futures import ThreadPoolExecutor, as_completed +from typing import Callable, List, Optional, TypeVar +from .config import Config +from .env import Environment + +T = TypeVar("T") +R = TypeVar("R") + + +def parallelize( + worker: Callable[[T], R], + items: List[T], + env: Environment, + executor: Optional[ThreadPoolExecutor] = None, +) -> List[R]: + if executor is None: + executor = ThreadPoolExecutor(max_workers=Config.max_workers) + with executor as exec: + futures = [exec.submit(worker, item) for item in items] + env.logger.info(f"submitted {len(futures)} tasks to executor ₰˜.༄") + return [f.result() for f in as_completed(futures)] diff --git a/dots_manager/setup.py b/dots_manager/setup.py new file mode 100644 index 0000000..d04a0e0 --- /dev/null +++ b/dots_manager/setup.py @@ -0,0 +1,19 @@ +from setuptools import setup, find_packages + +setup( + name="dotfiles", + version="0.1.0", + packages=find_packages(), + install_requires=[ + "jinja2", + ], + entry_points={"console_scripts": ["dotfiles-manager=dotfiles_manager.cli:main"]}, + python_requires=">=3.8", + include_package_data=True, + description="A cute dotfiles manager with Jinja2 templating and GNU Stow integration", + author="Your Name", + classifiers=[ + "Programming Language :: Python :: 3", + "Operating System :: OS Independent", + ], +) diff --git a/dots_manager/shell.py b/dots_manager/shell.py new file mode 100644 index 0000000..afd705a --- /dev/null +++ b/dots_manager/shell.py @@ -0,0 +1,14 @@ +import subprocess +import logging +from typing import List, Optional + + +def run_shell_command(cmd: List[str], logger: logging.Logger) -> Optional[str]: + logger.debug(f"running command: {cmd}") + try: + return subprocess.run( + cmd, capture_output=True, text=True, check=True + ).stdout.strip() + except subprocess.CalledProcessError as e: + logger.error(f"command failed: {cmd}, {e.stderr}") + return None diff --git a/dots_manager/stow.py b/dots_manager/stow.py new file mode 100644 index 0000000..52a9e36 --- /dev/null +++ b/dots_manager/stow.py @@ -0,0 +1,40 @@ +from pathlib import Path +from typing import Literal +from .env import Environment +from .shell import run_shell_command +from .parallel import parallelize +from .utils import is_some + + +def list_stowable_packages(packages: Path) -> list[Path]: + denylist = [".", "__"] + return [ + d + for d in packages.iterdir() + if d.is_dir() and all(y not in d.name for y in denylist) + ] + + +def apply_stow_operation_to_packages( + packages: Path, + target: Path, + stow_op: Literal["-D", "--no-folding"], + env: Environment, +) -> bool: + if not run_shell_command(["stow", "--version"], env.logger): + env.logger.error("stow not installed D:") + return False + + package_command = ["stow", "-d", str(packages), "-t", str(target), stow_op] + _packages = list_stowable_packages(packages) + env.logger.debug(f"found dotfile packages: {_packages}") + + commands = [package_command + [package.name] for package in _packages] + env.logger.debug(f"stowing packages: {commands}") + + results = parallelize( + lambda command: is_some(run_shell_command, command, env.logger)[0], + commands, + env, + ) + return len(commands) == sum(1 if x else 0 for x in results) diff --git a/dots_manager/template.py b/dots_manager/template.py new file mode 100644 index 0000000..a88158a --- /dev/null +++ b/dots_manager/template.py @@ -0,0 +1,73 @@ +from pathlib import Path +from typing import Optional +import shutil +import jinja2 +from .config import Config +from .parallel import parallelize +from .utils import is_some +from .env import Environment + + +def is_template(path: Path) -> bool: + return path.suffix == Config.template_extension + + +def find_templates_under(destination: Path) -> list[Path]: + return [ + t + for t in destination.glob("**/*" + Config.template_extension) + if t.is_file() and is_template(t) + ] + + +def render_template_to( + template: Path, destination: Path, env: Environment +) -> Optional[Path]: + env.logger.debug(f"rendering template {template} to {destination} ✧*ฺ") + if not is_template(template): + env.logger.error(f"{template} is not a valid template D:") + return None + + jinja_env = jinja2.Environment( + loader=jinja2.BaseLoader, + undefined=jinja2.StrictUndefined, + trim_blocks=True, + lstrip_blocks=True, + ) + rendered = render(template, jinja_env, env) + if not rendered: + env.logger.error(f"failed to render template {template}") + return None + + destination.write_text(rendered) + return destination + + +def render( + source: Path, jinja_env: jinja2.Environment, env: Environment +) -> Optional[str]: + try: + env.logger.debug(f"rendering template {source} ✿.。.:・") + return jinja_env.from_string(source.read_text()).render(**env.context) + except Exception as e: + env.logger.error(f"couldn’t render {source}: {e}") + return None + + +def compile_dotfiles(source: Path, output: Path, env: Environment) -> bool: + env.logger.info(f"compiling {source} to {output}") + output.mkdir(exist_ok=True, parents=True) + shutil.copytree(source, output, dirs_exist_ok=True) + + tasks = parallelize( + lambda template: is_some( + render_template_to, + template, + template.with_suffix(""), + env, + )[0] + and (template.unlink() is None), + find_templates_under(output), + env, + ) + return all(tasks) diff --git a/dots_manager/utils.py b/dots_manager/utils.py new file mode 100644 index 0000000..e8210cc --- /dev/null +++ b/dots_manager/utils.py @@ -0,0 +1,25 @@ +from typing import Callable, Optional, TypeVar, Tuple, ParamSpec, Dict, Any +from functools import reduce + +P = ParamSpec("P") +T = TypeVar("T") + + +def is_some( + callable: Callable[P, Optional[T]], *args: P.args, **kwargs: P.kwargs +) -> Tuple[bool, Optional[T]]: + result = callable(*args, **kwargs) + return result is not None, result + + +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, {}) diff --git a/main.py b/main.py deleted file mode 100755 index 34dbc04..0000000 --- a/main.py +++ /dev/null @@ -1,255 +0,0 @@ -#!/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() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..406920c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,20 @@ +[project] +name = "dots" +version = "0.1.0" +description = "my dotfiles manager with jinja2 templating and gnu stow integration" +authors = [{ name = "emprespresso" }] +readme = "README.md" +requires-python = ">=3.8" +dependencies = [ + "jinja2", +] + +[project.scripts] +dots = "dots_manager.cli:main" + +[tool.setuptools] +packages = ["dots_manager"] + +[build-system] +requires = ["setuptools>=61", "wheel"] +build-backend = "setuptools.build_meta" |