feat: rewrite setup script in python

This commit is contained in:
Oscar Wallberg
2025-08-01 13:21:14 +02:00
parent ced9d30f82
commit 20dc7268e2
3 changed files with 377 additions and 318 deletions
Executable
+371
View File
@@ -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()
+6 -1
View File
@@ -62,6 +62,7 @@ select = [
] ]
ignore = [ ignore = [
"A001", "A001",
"D100",
"D101", "D101",
"D202", "D202",
"D203", "D203",
@@ -69,7 +70,11 @@ ignore = [
"D301", "D301",
"D413", "D413",
"TC006", "TC006",
"COM812" "COM812",
"PLR0913",
"PLR0917",
"PYI011",
"UP031"
] ]
[tool.pyrefly] [tool.pyrefly]
-317
View File
@@ -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