diff options
Diffstat (limited to 'bin/pycombine')
-rwxr-xr-x | bin/pycombine | 240 |
1 files changed, 240 insertions, 0 deletions
diff --git a/bin/pycombine b/bin/pycombine new file mode 100755 index 0000000..a9d2338 --- /dev/null +++ b/bin/pycombine @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 + +""" +Copyright 2019 Raúl Benencia + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see <http://www.gnu.org/licenses/>. +""" + + +import argparse +import os +import sys +import re + + +class DirCombiner(): + ignore_files = ['.known', '.gitignore'] + ignore_dirs = ['.git', '.svn', '_darcs'] + + def __init__(self, include, exclude, status_filename, verbose): + self.include = re.compile(include) + self.exclude = re.compile(exclude) + self.status_filename = status_filename + self.verbose = verbose + + def _filter_files(self, filenames): + return [ + fn for fn in filenames + if fn not in DirCombiner.ignore_files + and self.include.match(fn) + and not self.exclude.match(fn) + ] + + def _filter_dirs(self, directories): + return [ + sd for sd in directories + if sd not in DirCombiner.ignore_dirs + and self.include.match(sd) + and not self.exclude.match(sd) + ] + + def _create_symlinks(self, status, dest, dirpath, filenames): + # Create symlinks for each file + for f in filenames: + src = os.path.abspath(os.path.normpath(os.path.join(dirpath, f))) + dst = os.path.normpath(os.path.join(dest, dirpath, f)) + + status.mark_as_seen(dst) + replace_msg = "" + if os.path.lexists(dst): + if not os.path.islink(dst): + sys.stderr.write( + "{} in {} is also in {}\n".format(f, dirpath, dest) + ) + continue + elif os.path.realpath(dst) != src: + replace_msg = "(previously pointing to: {})".format( + os.path.realpath(dst)) + try: + os.remove(dst) + except IOError: + raise DeleteFileError(dst) + else: + # Symlink is already pointing to the current file + continue + + try: + os.symlink(src, dst) + except OSError: + raise CreateSymlinkError(dst) + + if replace_msg == "" or (replace_msg != "" and self.verbose): + sys.stdout.write("{} -> {} {}\n".format(src, dst, replace_msg)) + + def _create_directories(self, dest, dirpath, subdirs): + for sd in subdirs: + dst = os.path.normpath(os.path.join(dest, dirpath, sd)) + if os.path.lexists(dst): + if os.path.isdir(dst): + continue + else: + raise DirIsFileError(dst) + + try: + os.mkdir(dst) + except OSError: + raise CreateDirError(dst) + + sys.stdout.write(f"{dst} created\n") + + def combine(self, dest, directories): + # Loop through all the directories. The later ones can override + # links from the previous ones. + for directory in directories: + try: + os.chdir(directory) + except OSError: + raise ChangeDirError(directory) + + status = DirCombinerStatus(self.status_filename) + for dirpath, subdirs, filenames in os.walk('.'): + # Modify filenames and subdirs in place to reduce the + # scope of the search. + subdirs[:] = self._filter_dirs(subdirs) + filenames[:] = self._filter_files(filenames) + + self._create_symlinks(status, dest, dirpath, filenames) + self._create_directories(dest, dirpath, subdirs) + + status.finish() + + +class DirCombinerStatus(): + def __init__(self, status_filename): + self._seen = [] + self._status_filename = status_filename + + try: + if os.path.exists(self._status_filename): + if not os.path.isfile(self._status_filename): + raise StatusIsNotFileError(self._status_filename) + else: + with open(self._status_filename, 'r') as f: + self._known = f.read().split('\n')[:-1] + else: + self._known = [] + except IOError: + raise StatusFileError(self._status_filename) + + def _clean_old(self): + old = {f for f in self._known if f not in self._seen} + for f in old: + try: + if os.path.lexists(f): + rp = os.path.realpath(f) + if not os.path.islink(f) or os.path.exists(rp): + sys.stderr.write( + f"Not deleting {f} as it's a valid file\n") + else: + os.remove(f) + sys.stdout.write(f"Deleted old file {f}\n") + except IOError: + raise DeleteFileError(f) + + def mark_as_seen(self, filename): + self._seen.append(filename) + + def finish(self): + self._clean_old() + with open(self._status_filename, 'w') as f: + for filename in self._seen: + f.write(filename + '\n') + + +class Error(Exception): + """Base class for exceptions in this program """ + msg = '' + + def __init__(self, param): + self.param = param + + def message(self): + return self.msg.format(self.param) + '\n' + + +class DirIsFileError(Error): + msg = 'Target directory {} already exists as a non-dir file.' + + +class CreateDirError(Error): + msg = 'Failure creating directory {}. Do you have appropriate permissions?' + + +class CreateSymlinkError(Error): + msg = 'Failure creating symlink {}. Do you have appropriate permissions?' + + +class ChangeDirError(Error): + msg = 'Failure changing to dir {}. Do you have appropriate permissions?' + + +class StatusFileError(Error): + msg = 'Error while opening status file {}. ' + \ + 'Do you have appropriate permissions?' + + +class StatusIsNotFile(Error): + msg = 'The status path {} does not have a file. Maybe it holds an old dir?' + + +class DeleteFileError(Error): + msg = 'Unable to delete file {}. Do you have appropriate permissions?' + + +def main(): + parser = argparse.ArgumentParser() + + parser.add_argument('-i', '--include', default='.*', + help='regex for filenames to include') + + parser.add_argument('-e', '--exclude', default='^$', + help='regex for filenames to exclude') + + parser.add_argument('-s', '--status-filename', default='.known', + help='regex for filenames to exclude') + + parser.add_argument('dest', metavar='dest', type=str, + help='directory where to install links and filenames') + + parser.add_argument('dirs', metavar='dir', type=str, nargs='+', + help='directories to retrieve filenames from') + + parser.add_argument('-v', '--verbose', default=False, + help='Verbose output') + + + args = parser.parse_args() + dc = DirCombiner(args.include, args.exclude, args.status_filename, args.verbose) + + try: + dc.combine(args.dest, args.dirs) + + except Error as e: + sys.stderr.write(e.message()) + sys.exit(1) + + +if __name__ == "__main__": + main() |