From fa8ada9e0f2dfeec364d7866ba4e323555be246b Mon Sep 17 00:00:00 2001 From: Andrew Williams Date: Sat, 30 Jan 2021 15:30:09 +0000 Subject: [PATCH] Add stowage --- bin/bin/stowage | 192 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100755 bin/bin/stowage diff --git a/bin/bin/stowage b/bin/bin/stowage new file mode 100755 index 0000000..40d1d6d --- /dev/null +++ b/bin/bin/stowage @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +""" +stowage +by Keith Gaughan +Stow, but in Python, and in a single file. +Copyright (c) Keith Gaughan, 2017. +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +import argparse +import fnmatch +import os +from os import path +import re +import shutil +import sys + + +def add(args): + target = path.realpath(args.target) + file_path = path.realpath(args.add) + package = path.realpath(args.packages[0]) + if path.commonprefix([target, file_path]) != target: + print(f"error: '{args.add}' not under '{args.target}'", + file=sys.stderr) + 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) + if not args.dry_run: + shutil.move(file_path, dest) + # XXX Should really check if the symlink fails here. + os.symlink(dest_path, file_path) + + +def install(args, is_excluded): + for package in args.packages: + if not path.isdir(package): + print(f"no such package: {package}; skipping", file=sys.stderr) + continue + for root, _, files in os.walk(package, followlinks=True): + files = [ + filename for filename in files if not is_excluded(filename)] + if len(files) == 0: + continue + rest = root[len(package) + 1:] + dest = path.join(args.target, rest) + if rest != "": + if args.verbose: + print("DIR", dest) + if not args.dry_run and not os.path.exists(dest): + os.makedirs(dest, mode=0o755) + for filename in files: + dest_path = path.join(dest, filename) + if path.exists(dest_path): + if args.verbose: + print("SKIP", dest_path) + continue + src_path = path.realpath(path.join(root, filename)) + if args.verbose: + print("LINK", src_path, dest_path) + if not args.dry_run: + if path.islink(dest_path): + if args.verbose: + print("DANGLE", dest_path) + os.unlink(dest_path) + os.symlink(src_path, dest_path) + + +def uninstall(args, is_excluded): + dirs = [] + for package in args.packages: + if not path.isdir(package): + print(f"no such package: {package}; skipping", file=sys.stderr) + continue + for root, _, files in os.walk(package, followlinks=True): + files = [ + filename for filename in files if not is_excluded(filename)] + if len(files) == 0: + continue + rest = root[len(package) + 1:] + dest = path.join(args.target, rest) + if rest != "": + 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) + if not args.dry_run: + os.unlink(dest_path) + elif args.verbose: + print("SKIP", dest_path) + elif args.verbose: + print("SKIP", dest_path) + + # Delete the directories if empty. + for dir_path in sorted(dirs, key=len, reverse=True): + try: + if args.verbose: + print("RMDIR", dir_path) + if not args.dry_run: + os.rmdir(dir_path) + except OSError: + pass + + +def make_argparser(): + parser = argparse.ArgumentParser(description="A symlink farm manager.") + parser.add_argument("--verbose", "-v", + action="store_true", help="Verbose output") + parser.add_argument( + "--target", + "-t", + default=os.path.expanduser('~'), + help="Target directory in which to place symlinks", + ) + parser.add_argument( + "--exclude", + "-x", + action="append", + default=[], + metavar="GLOB", + help="Glob pattern of files to exclude", + ) + parser.add_argument("--dry-run", "-n", + action="store_true", help="Dry run.") + + group = parser.add_mutually_exclusive_group(required=False) + group.add_argument( + "--uninstall", + "-D", + action="store_false", + dest="install", + help="Uninstall symlinks", + ) + group.add_argument( + "--add", "-a", metavar="FILE", help="Stow files in a particular package" + ) + + parser.add_argument( + "packages", metavar="PACKAGE", nargs="+", help="Packages to install" + ) + return parser + + +def main(): + parser = make_argparser() + args = parser.parse_args() + exclude = [re.compile(fnmatch.translate(pattern)) + for pattern in args.exclude] + + def is_excluded(filename): + return any(pattern.match(filename) for pattern in exclude) + + if args.add: + if len(args.packages) > 1: + parser.error("--add only works with a single package") + args.add = path.normpath(path.join(args.target, args.add)) + if not path.isfile(args.add): + parser.error(f"no such file: {args.add}") + add(args) + elif args.install: + install(args, is_excluded) + else: + uninstall(args, is_excluded) + + +if __name__ == "__main__": + main()