#!/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 . """ 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()