From 20dc7268e2c34813f9f42a5c1e68a4c681250851 Mon Sep 17 00:00:00 2001 From: Oscar Wallberg Date: Fri, 1 Aug 2025 13:21:14 +0200 Subject: [PATCH] feat: rewrite setup script in python --- install.py | 371 +++++++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 7 +- setup.sh | 317 ------------------------------------------ 3 files changed, 377 insertions(+), 318 deletions(-) create mode 100755 install.py delete mode 100755 setup.sh diff --git a/install.py b/install.py new file mode 100755 index 0000000..0d33710 --- /dev/null +++ b/install.py @@ -0,0 +1,371 @@ +#!/usr/bin/env python3 + +import argparse +import platform +import shutil +import subprocess +import sys +from pathlib import Path + + +class InstallerError(BaseException): ... + + +class Installer: + def __init__(self, profile: str) -> None: + """Initialize a new installer.""" + self.script_file = Path(__file__).resolve() + self.script_name = self.script_file.name + self.script_dir = self.script_file.parent + self.source_dir = self.script_dir + self.dest_dir = Path.home() + + self.distro = platform.freedesktop_os_release().get("NAME", "Unknown") + self.profile = profile + self.has_error = False + + # Package query functions by distro + self.pkg_query = {"Arch Linux": self._pkg_query_arch} + + # Packages by distro and profile + self.pkgs = { + "Arch Linux hyprland": [ + "alacritty", + "tmux", + "zsh", + "hyprland", + "uwsm", + "fnott", + "pipewire", + "wireplumber", + "hyprpolkitagent", + "qt5-wayland", + "qt6-wayland", + "hyprlock", + "hypridle", + "xdg-desktop-portal-hyprland", + "xdg-desktop-portal-gtk", + "hyprland-qt-support", + "waybar", + "rofi", + "wl-clipboard", + "dolphin", + "pasystray", + "playerctl", + "brightnessctl", + "breeze", + "pavucontrol", + "otf-font-awesome", + ] + } + + self.symlinks = [ + ".config/alacritty", + ".config/dolphinrc", + ".config/dunst", + ".config/fish", + ".config/fnott", + ".config/foot", + ".config/frogminer", + ".config/ghostty", + ".config/hypr", + ".config/i3", + ".config/i3status", + ".config/lf", + ".config/kde-mimeapps.list", + ".config/kdeglobals", + ".config/kglobalshortcutsrc", + ".config/klipperrc", + ".config/kwinrc", + ".config/picom", + ".config/rofi", + ".config/strawberry", + ".config/tmux", + ".config/uwsm", + ".config/waybar", + ".config/wezterm", + ".config/xdg-desktop-portal", + ".config/yay", + ".config/zed", + ".local/bin", + ".local/share/fonts", + ".local/share/konsole", + ".local/share/kxmlgui5/dolphin/dolphinui.rc", + ".vimrc", + ".xinit-scripts", + ".xinitrc", + ".Xresources", + ".zprofile", + ] + + self.copies = [ + ".config/gtk-3.0", + ".config/gtk-4.0", + ".gtkrc-2.0", + ] + + self.symlink_map = {"zsh/rc": ".zshrc"} + + @staticmethod + def _pkg_query_arch(package: str) -> bool: + """ + Check if package is installed on Arch Linux using pacman. + + Returns: + True if package is found, otherwise False. + """ + result = subprocess.run( + ["pacman", "-Qi", package], + capture_output=True, + text=True, + check=False, + ) + return result.returncode == 0 + + def error(self, msg: str) -> None: + """Print error message and set error flag.""" + self.has_error = True + print(f"Error: {msg}", file=sys.stderr) + + def check_packages_installed(self) -> None: + """Check if required packages are installed.""" + key = f"{self.distro} {self.profile}" + if key not in self.pkgs: + self.error(f"No package list defined for {key}") + return + + if self.distro not in self.pkg_query: + self.error(f"No package query function for {self.distro}") + return + + missing: list[str] = [] + pkg_list = self.pkgs[key] + query_func = self.pkg_query[self.distro] + + for pkg in pkg_list: + if not query_func(pkg): + missing.append(pkg) + + if missing: + self.error(f"missing {len(missing)} packages: {' '.join(missing)}") + + def remove_symlink(self, rel_path: str) -> None: + """ + Remove a symlink. + + Raises: + InstallerError: on error + """ + link_path = self.dest_dir / rel_path + if link_path.is_symlink(): + src = link_path.resolve() + print(f"Removing symlink: {link_path} -> {src}") + link_path.unlink() + elif link_path.exists(): + raise InstallerError( + f"object to be removed is not a symlink: {link_path}" + ) + + def remove_all_symlinks(self) -> None: + """Remove all symlinks.""" + for link in self.symlinks: + self.remove_symlink(link) + + for dst in self.symlink_map.values(): + self.remove_symlink(dst) + + def create_symlink( + self, + rel_src: str, + rel_dst: str, + force: bool = False, + ignore: bool = False, + ) -> None: + """ + Create a symlink. + + Raises: + InstallerError: on error + """ + if not rel_src or not rel_dst: + raise InstallerError( + f"missing src or dst argument: src='{rel_src}', dst='{rel_dst}'" + ) + + src = self.script_dir / rel_src + dst = self.dest_dir / rel_dst + dst_parent = dst.parent + + if not src.exists(): + raise InstallerError(f"source path does not exist: {src}") + + # Create parent directory if needed + if not dst_parent.exists(): + print(f"Creating parent: {dst_parent}") + dst_parent.mkdir(parents=True, exist_ok=True) + + if dst.is_symlink(): + if ignore or dst.resolve() == src: + return + + if force: + self.remove_symlink(rel_dst) + else: + raise InstallerError(f"symbolic link already exists: {dst}") + elif dst.exists(): + raise InstallerError( + f"path already exists and is not a symlink: {dst}" + ) + + print(f"Creating link: {dst} -> {rel_src}") + dst.symlink_to(src) + + @staticmethod + def remove_path(target: Path) -> None: + """Remove path using gio trash if available, otherwise rm.""" + print(f"Trashing item: {target}") + subprocess.run(["gio", "trash", str(target)], check=True) + + def copy_item( + self, + rel_src: str, + rel_dst: str, + force: bool = False, + ignore_existing: bool = False, + ) -> None: + """ + Copy an item. + + Raises: + InstallerError: on error + """ + if not rel_src or not rel_dst: + raise InstallerError( + f"missing src or dst argument: src='{rel_src}', dst='{rel_dst}'" + ) + + src = self.script_dir / rel_src + dst = self.dest_dir / rel_dst + dst_parent = dst.parent + + if not src.exists(): + raise InstallerError(f"source path does not exist: {src}") + + # Create parent directory if needed + if not dst_parent.exists(): + print(f"Creating parent: {dst_parent}") + dst_parent.mkdir(parents=True, exist_ok=True) + + if dst.exists(): + if force: + self.remove_path(dst) + elif ignore_existing: + return + else: + raise InstallerError(f"path already exists: {dst}") + + print(f"Copying item: from {rel_src} to {dst_parent}/") + if src.is_dir(): + shutil.copytree(src, dst) + else: + shutil.copy2(src, dst) + + def create_all_symlinks( + self, force: bool = False, ignore_existing: bool = False + ) -> None: + """Create all symlinks.""" + for link in self.symlinks: + self.create_symlink(link, link, force, ignore_existing) + + for src, dst in self.symlink_map.items(): + self.create_symlink(src, dst, force, ignore_existing) + + def copy_all_items( + self, force: bool = False, ignore_existing: bool = False + ) -> None: + """Copy all items.""" + for item in self.copies: + self.copy_item(item, item, force, ignore_existing) + + def check_terminfo(self) -> None: + """Check if tmux-256color terminfo is available.""" + try: + result = subprocess.run( + ["infocmp", "tmux-256color"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + self.error( + "Missing terminfo for tmux-256color. Try installing ncurses-term." + ) + except FileNotFoundError: + self.error("infocmp command not found") + + def run( + self, + force: bool = False, + ignore: bool = False, + remove: bool = False, + ) -> None: + """Run main run function.""" + if remove: + self.remove_all_symlinks() + return + + self.check_packages_installed() + self.check_terminfo() + self.create_all_symlinks(force, ignore) + self.copy_all_items(force, ignore) + + +def main() -> None: + """Run installer.""" + parser = argparse.ArgumentParser( + description="Linux dotfiles setup script", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "profile", + nargs="?", + default="hyprland", + help="Profile to install (default: hyprland)", + ) + parser.add_argument( + "-f", + "--force", + action="store_true", + help="Overwrite any existing links", + ) + parser.add_argument( + "-i", + "--ignore", + action="store_true", + help="Ignore existing symlinks", + ) + parser.add_argument( + "-r", + "--remove", + action="store_true", + help="Remove any existing symlinks", + ) + + args = parser.parse_args() + + installer = Installer(args.profile) + try: + installer.run( + force=args.force, + ignore=args.ignore, + remove=args.remove, + ) + except InstallerError as e: + installer.error(str(e)) + + if installer.has_error: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 60a3053..9124ad5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,6 +62,7 @@ select = [ ] ignore = [ "A001", + "D100", "D101", "D202", "D203", @@ -69,7 +70,11 @@ ignore = [ "D301", "D413", "TC006", - "COM812" + "COM812", + "PLR0913", + "PLR0917", + "PYI011", + "UP031" ] [tool.pyrefly] diff --git a/setup.sh b/setup.sh deleted file mode 100755 index eec08a7..0000000 --- a/setup.sh +++ /dev/null @@ -1,317 +0,0 @@ -#!/usr/bin/env zsh - -set -e - -DISTRO="$(sed -n 's/^NAME="\([^"]\+\)"/\1/p' /etc/os-release)" - -SCRIPT_FILE="$(readlink -f -- "$0")" -SCRIPT_NAME="$(basename -- "$SCRIPT_FILE")" -SCRIPT_DIR="$(dirname -- "$SCRIPT_FILE")" - -SOURCE_DIR="$SCRIPT_DIR" -DEST_DIR="$HOME" - -PRINT_HELP=false -FORCE=false -REMOVE_EXISTING=false -IGNORE_EXISTING=false -ERROR=false -PROFILE="hyprland" - -pkg_query_arch() { - pacman -Qi "$@" -} - -typeset -A PKG_QUERY -PKG_QUERY["Arch Linux"]="pkg_query_arch" - -typeset -A PKGS -PKGS["Arch Linux hyprland"]="alacritty tmux zsh hyprland uwsm fnott pipewire wireplumber hyprpolkitagent qt5-wayland qt6-wayland hyprlock hypridle xdg-desktop-portal-hyprland xdg-desktop-portal-gtk hyprland-qt-support waybar rofi wl-clipboard dolphin pasystray playerctl brightnessctl breeze pavucontrol otf-font-awesome" - -# Define paths to symlink -typeset -a SYMLINKS -SYMLINKS=( - ".config/alacritty" - ".config/dolphinrc" - ".config/dunst" - ".config/fish" - ".config/fnott" - ".config/foot" - ".config/frogminer" - ".config/ghostty" - ".config/hypr" - ".config/i3" - ".config/i3status" - ".config/lf" - ".config/kde-mimeapps.list" - ".config/kdeglobals" - ".config/kglobalshortcutsrc" - ".config/klipperrc" - ".config/kwinrc" - ".config/picom" - ".config/rofi" - ".config/strawberry" - ".config/tmux" - ".config/uwsm" - ".config/waybar" - ".config/wezterm" - ".config/xdg-desktop-portal" - ".config/yay" - ".config/zed" - ".local/bin" - ".local/share/fonts" - ".local/share/konsole" - ".local/share/kxmlgui5/dolphin/dolphinui.rc" - ".vimrc" - ".xinit-scripts" - ".xinitrc" - ".Xresources" - ".zprofile" -) - -typeset -a COPIES -COPIES=( - ".config/gtk-3.0" - ".config/gtk-4.0" - ".gtkrc-2.0" -) - -typeset -A SYMLINK_MAP -SYMLINK_MAP[zsh/rc]=".zshrc" - -error() { - msg="$@" - ERROR=true - echo "Error: $msg" >&2 -} - -check_packages_installed() { - local -a missing - local -a pkgs - - pkgs=(${=PKGS["$DISTRO $PROFILE"]}) - for pkg in "${pkgs[@]}"; do - if ! ${PKG_QUERY["$DISTRO"]} "$pkg" >/dev/null 2>&1; then - missing+=($pkg) - fi - done - - if [ ${#missing[@]} -gt 0 ]; then - error "missing ${#missing[@]} packages:" "${missing[@]}" - fi -} - -remove_symlink() { - local link src - link="${DEST_DIR}/$1" - - if test -L "$link"; then - src="$(readlink -f -- "$link")" - echo "Removing symlink: $link -> $src" - rm "$link" - elif test -e "$link"; then - error "object to be removed is not a symlink:" - error "${link}: $(stat -c '%F' -- "$link")" - - return 1 - fi -} - -remove_all_symlinks() { - for link in "${SYMLINKS[@]}"; do - remove_symlink "$link" - done - - for src dst in ${(kv)SYMLINK_MAP}; do - remove_symlink "$dst" - done -} - -create_symlink() { - local rel_src rel_dst src dst dst_parent - - rel_src="$1" - rel_dst="$2" - - if test -z "$rel_src"; then - error "missing src argument:" - error "$0 $@" - return 1 - fi - - if test -z "$rel_dst"; then - error "missing dst argument:" - error "$0 $@" - return 1 - fi - - src="${SCRIPT_DIR}/$rel_src" - dst="${DEST_DIR}/$rel_dst" - dst_parent="$(dirname -- "$dst")" - - if ! test -e "$src"; then - error "the following source path does not exist:" - error "$src" - return 1 - fi - - if ! test -d "$dst_parent"; then - echo "Creating parent: $dst_parent" - mkdir -p "$dst_parent" - fi - - if test -L "$dst"; then - if $FORCE; then - if test "$(readlink -f "$dst")" != "$src"; then - remove_symlink "$2" - else - return 0 - fi - elif $IGNORE_EXISTING; then - return 0 - else - error "symbolic link already exists:" - error "$dst" - return 1 - fi - elif test -e "$dst"; then - error "path already exists that is not a symlink:" - error "${dst}: $(stat -c '%F' -- "$dst")" - return 1 - fi - - echo "Creating link: $dst -> $rel_src" - ln -s "$src" "$dst" -} - -remove_path() { - local target - target="$1" - - echo "Trashing item: $target" - gio trash "$target" -} - -copy_item() { - local rel_src rel_dst src dst dst_parent - - rel_src="$1" - rel_dst="$2" - - if test -z "$rel_src"; then - error "missing src argument:" - error "$0 $@" - return 1 - fi - - if test -z "$rel_dst"; then - error "missing dst argument:" - error "$0 $@" - return 1 - fi - - src="${SCRIPT_DIR}/$rel_src" - dst="${DEST_DIR}/$rel_dst" - dst_parent="$(dirname -- "$dst")" - - if ! test -e "$src"; then - error "the following source path does not exist:" - error "$src" - return 1 - fi - - if ! test -d "$dst_parent"; then - echo "Creating parent: $dst_parent" - mkdir -p "$dst_parent" - fi - - if test -e "$dst"; then - if $FORCE; then - remove_path "$dst" - elif $IGNORE_EXISTING; then - return 0 - else - error "path already exists:" - error "${dst}" - return 1 - fi - fi - - echo "Copying item: from $rel_src to ${dst_parent}/" - cp -r "$src" "${dst_parent}/" -} - -create_all_symlinks() { - for link in "${SYMLINKS[@]}"; do - create_symlink "$link" "$link" - done - - for src dst in ${(kv)SYMLINK_MAP}; do - create_symlink "$src" "$dst" - done -} - -copy_all_items() { - for item in "${COPIES[@]}"; do - copy_item "$item" "$item" - done -} - -check_terminfo() { - if ! infocmp tmux-256color > /dev/null; then - error "Missing terminfo for tmux-256color. Try installing ncurses-term." - return 1 - fi -} - - -print_help() { - echo "Usage: $SCRIPT_NAME [...]" - echo "" - echo "Options:" - echo " -h, --help Print this help message" - echo " -f, --force Overwrite any existing links" - echo " -i, --ignore-existing Ignore existing symlinks" - echo " -r, --remove-existing Remove any existing symlinks" -} - -while [ $# -gt 0 ]; do - case $1 in - -h|--help) - PRINT_HELP=true - shift - ;; - -f|--force) - FORCE=true - shift - ;; - -r|--remove-existing) - REMOVE_EXISTING=true - shift - ;; - -i|--ignore-existing) - IGNORE_EXISTING=true - shift - ;; - *) - error "unknown option: $1" - PRINT_HELP=true - shift - ;; - esac -done - -if $PRINT_HELP; then - print_help -elif $REMOVE_EXISTING; then - remove_all_symlinks -else - check_terminfo - check_packages_installed - create_all_symlinks - copy_all_items -fi - -if $ERROR; then - exit 1 -fi