[bin] Improve Stowage

Switch to using logging, and modernized Python usage.
This commit is contained in:
2025-12-13 13:51:36 +00:00
parent 088491234b
commit 1bb76e4b24

View File

@@ -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__":