aboutsummaryrefslogtreecommitdiff
path: root/bin/pycombine
diff options
context:
space:
mode:
Diffstat (limited to 'bin/pycombine')
-rwxr-xr-xbin/pycombine240
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()
nihil fit ex nihilo