diff --git a/bin/bin/stowage b/bin/bin/stowage index 8bd8d99..b78a73f 100755 --- a/bin/bin/stowage +++ b/bin/bin/stowage @@ -27,144 +27,160 @@ SOFTWARE. import argparse import fnmatch +import logging import os -from os import path import re import shutil import sys +from pathlib import Path +from typing import Callable, List + +logger = logging.getLogger(__name__) -def add(args): - target = path.realpath(args.target) - file_path = path.realpath(args.file) - package = path.realpath(path.join(args.repository, args.packages[0])) - if path.commonprefix([target, file_path]) != target: - print(f"error: '{args.add}' not under '{args.target}'", file=sys.stderr) +def add(args: argparse.Namespace) -> None: + """Add a file to a package by moving it and creating a symlink.""" + target = Path(args.target).resolve() + file_path = Path(args.file).resolve() + package = Path(args.repository, args.packages[0]).resolve() + + if not file_path.is_relative_to(target): + logger.error("'%s' not under '%s'", args.file, args.target) sys.exit(1) - rest = file_path[len(target) + 1 :] - dest_path = path.join(package, rest) - dest = path.dirname(dest_path) - if not path.exists(dest): - if args.verbose: - print("DIR", dest) - os.makedirs(dest, mode=0o755) - if args.verbose: - print("SWAP", dest_path, file_path) + + rest = file_path.relative_to(target) + dest_path = package / rest + dest_dir = dest_path.parent + + if not dest_dir.exists(): + logger.info("DIR %s", dest_dir) + if not args.dry_run: + dest_dir.mkdir(parents=True, mode=0o755, exist_ok=True) + + logger.info("SWAP %s %s", dest_path, file_path) if not args.dry_run: - shutil.move(file_path, dest) - # TODO Should really check if the symlink fails here. - os.symlink(dest_path, file_path) + shutil.move(str(file_path), str(dest_path)) + try: + file_path.symlink_to(dest_path) + except OSError as e: + logger.error("failed to create symlink: %s", e) + # Attempt to restore the file + shutil.move(str(dest_path), str(file_path)) + sys.exit(1) -def install(args, is_excluded): +def install(args: argparse.Namespace, is_excluded: Callable[[str], bool]) -> None: + """Install packages by creating symlinks from repository to target.""" for package in args.packages: - package_dir = path.join(args.repository, package) - if not path.isdir(package_dir): - print(f"no such package: {package}; skipping", file=sys.stderr) + package_dir = Path(args.repository, package) + if not package_dir.is_dir(): + logger.warning("no such package: %s; skipping", package) continue # Walk the package for root, _, files in os.walk(package_dir, followlinks=True): + root_path = Path(root) files = [filename for filename in files if not is_excluded(filename)] - if len(files) == 0: + if not files: continue - rest = root[len(package_dir) + 1 :] - dest = path.join(args.target, rest) + + rest = root_path.relative_to(package_dir) + dest = Path(args.target) / rest # Create the directory path - if rest != "": + if rest != Path("."): # If a non-directory exists with the same name and clobber is enabled, get rid of it. - if path.exists(dest) and not path.isdir(dest) and args.clobber: - if args.verbose: - print("UNLINK", dest) + if dest.exists() and not dest.is_dir() and args.clobber: + logger.info("UNLINK %s", dest) if not args.dry_run: - os.unlink(dest) + dest.unlink() # Make directory - if args.verbose: - print("DIR", dest) - if not args.dry_run and not path.exists(dest): - os.makedirs(dest, mode=0o755) + logger.info("DIR %s", dest) + if not args.dry_run and not dest.exists(): + dest.mkdir(parents=True, mode=0o755, exist_ok=True) # Process files for filename in files: - src_path = path.realpath(path.join(root, filename)) - dest_path = path.join(dest, filename) + src_path = (root_path / filename).resolve() + dest_path = dest / filename # Skip if the file exists and we're not clobbering - if path.exists(dest_path) and not args.clobber: - if args.verbose: - print("SKIP", dest_path) + if dest_path.exists() and not args.clobber: + logger.info("SKIP %s", dest_path) continue # Does the file already exist? - if path.isfile(dest_path): - if args.verbose: - print("UNLINK", dest_path) + if dest_path.is_file(): + logger.info("UNLINK %s", dest_path) if not args.dry_run: - os.unlink(dest_path) + dest_path.unlink() # Link the file - if args.verbose: - print("LINK", src_path, dest_path) + logger.info("LINK %s %s", src_path, dest_path) if not args.dry_run: - os.symlink(src_path, dest_path) + dest_path.symlink_to(src_path) -def uninstall(args, is_excluded): - dirs = [] +def uninstall(args: argparse.Namespace, is_excluded: Callable[[str], bool]) -> None: + """Uninstall packages by removing symlinks.""" + dirs: List[Path] = [] for package in args.packages: - package_dir = path.join(args.repository, package) - if not path.isdir(package_dir): - print(f"no such package: {package}; skipping", file=sys.stderr) + package_dir = Path(args.repository, package) + if not package_dir.is_dir(): + logger.warning("no such package: %s; skipping", package) continue + for root, _, files in os.walk(package_dir, followlinks=True): + root_path = Path(root) files = [filename for filename in files if not is_excluded(filename)] - if len(files) == 0: + if not files: continue - rest = root[len(package_dir) + 1 :] - dest = path.join(args.target, rest) - if rest != "": + + rest = root_path.relative_to(package_dir) + dest = Path(args.target) / rest + + if rest != Path("."): dirs.append(dest) + for filename in files: - dest_path = path.join(dest, filename) - if path.islink(dest_path): - src_path = path.realpath(path.join(root, filename)) - if path.realpath(dest_path) == src_path: - if args.verbose: - print("UNLINK", dest_path) + dest_path = dest / filename + if dest_path.is_symlink(): + src_path = (root_path / filename).resolve() + if dest_path.resolve() == src_path: + logger.info("UNLINK %s", dest_path) if not args.dry_run: - os.unlink(dest_path) - elif args.verbose: - print("SKIP", dest_path) - elif args.verbose: - print("SKIP", dest_path) + dest_path.unlink() + else: + logger.info("SKIP %s", dest_path) + else: + logger.info("SKIP %s", dest_path) # Delete the directories if empty. - for dir_path in sorted(dirs, key=len, reverse=True): + for dir_path in sorted(dirs, key=lambda p: len(str(p)), reverse=True): try: - if args.verbose: - print("RMDIR", dir_path) + logger.info("RMDIR %s", dir_path) if not args.dry_run: - os.rmdir(dir_path) + dir_path.rmdir() except OSError: pass -def make_argparser(): +def make_argparser() -> argparse.ArgumentParser: + """Create and configure the argument parser.""" parser = argparse.ArgumentParser(description="A dotfile package manager.") parser.add_argument("--verbose", "-v", action="store_true", help="Verbose output") parser.add_argument("--dry-run", "-n", action="store_true", help="Dry run.") parser.add_argument( "--target", "-t", - default=path.expanduser("~"), + default=str(Path.home()), help="Target directory in which to place symlinks", ) parser.add_argument( "--repository", "-r", - default=path.expanduser("~/.dotfiles"), + default=str(Path.home() / ".dotfiles"), help="The location of the dotfile repository", ) parser.add_argument( @@ -184,7 +200,7 @@ def make_argparser(): subparsers = parser.add_subparsers(dest="command", help="sub-command help") # List - parser_add = subparsers.add_parser("list", help="List packages in the repository") + subparsers.add_parser("list", help="List packages in the repository") # Add parser_add = subparsers.add_parser("add", help="Add a file to a package") @@ -208,31 +224,45 @@ def make_argparser(): return parser -def main(): +def main() -> None: + """Main entry point for the stowage script.""" parser = make_argparser() args = parser.parse_args() - if args.dry_run: - args.verbose = True + + # Configure logging + log_level = logging.INFO if args.verbose or args.dry_run else logging.WARNING + logging.basicConfig( + level=log_level, + format='%(message)s', + stream=sys.stdout + ) + exclude = [re.compile(fnmatch.translate(pattern)) for pattern in args.exclude] - def is_excluded(filename): + def is_excluded(filename: str) -> bool: + """Check if a filename matches any exclusion pattern.""" return any(pattern.match(filename) for pattern in exclude) if args.command == "list": - for dir in os.listdir(args.repository): - if path.isdir(path.join(args.repository, dir)) and dir[0] != ".": - print(dir) + repo_path = Path(args.repository) + for item in sorted(repo_path.iterdir()): + if item.is_dir() and not item.name.startswith("."): + print(item.name) elif args.command == "add": if len(args.packages) > 1: - parser.error("--add only works with a single package") - args.file = path.normpath(path.join(args.target, args.file)) - if not path.isfile(args.file): + parser.error("add only works with a single package") + file_path = Path(args.target, args.file) + if not file_path.is_file(): parser.error(f"no such file: {args.file}") + args.file = str(file_path) add(args) elif args.command == "install": install(args, is_excluded) elif args.command == "uninstall": uninstall(args, is_excluded) + else: + parser.print_help() + sys.exit(1) if __name__ == "__main__":