summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorElizabeth <me@liz.coffee>2025-06-02 15:54:32 -0700
committerElizabeth <me@liz.coffee>2025-06-02 15:54:32 -0700
commit03646d1e891271339ca256fadd15eaa8ac678911 (patch)
tree5df23a874fa1ae5c62dad318d9098225852f2137
parentd098e94ad102da9d018acca72ca5a5c554d25a01 (diff)
downloaddotfiles-03646d1e891271339ca256fadd15eaa8ac678911.tar.gz
dotfiles-03646d1e891271339ca256fadd15eaa8ac678911.zip
Make it a python package
-rw-r--r--.gitignore1
-rwxr-xr-xdots_manager/.main.py328
-rw-r--r--dots_manager/__init__.py1
-rw-r--r--dots_manager/args.py43
-rw-r--r--dots_manager/cli.py20
-rw-r--r--dots_manager/config.py14
-rw-r--r--dots_manager/env.py56
-rw-r--r--dots_manager/kawaii_logger.py (renamed from kawaii_logger.py)11
-rw-r--r--dots_manager/parallel.py21
-rw-r--r--dots_manager/setup.py19
-rw-r--r--dots_manager/shell.py14
-rw-r--r--dots_manager/stow.py40
-rw-r--r--dots_manager/template.py73
-rw-r--r--dots_manager/utils.py25
-rwxr-xr-xmain.py255
-rw-r--r--pyproject.toml20
16 files changed, 677 insertions, 264 deletions
diff --git a/.gitignore b/.gitignore
index d6ab7c8..4306b20 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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"