diff --git a/delete b/delete index 58cb9c6..e5d9b4f 100755 --- a/delete +++ b/delete @@ -4,30 +4,14 @@ import logging import optparse import os import shutil -import stat import sys -import afs.fs +import libdelete +from libdelete import perror logger = logging.getLogger('delete') whoami = os.path.basename(sys.argv[0]) -import libdelete - -def debug_callback(option, opt_str, value, parser): - """ - An OptionParser callback that enables debugging. - """ - all_loggers = [logger.name, 'libdelete'] - loggers = [x.strip() for x in value.split(',')] - if value.lower() == 'all': - loggers = all_loggers - else: - if not set(loggers) == set(all_loggers): - parser.error('Valid debug targets: {0}'.format( - ", ".join(all_loggers))) - for l in loggers: - logging.getLogger(l).setLevel(logging.DEBUG) def ask(question, *args, **kwargs): """ @@ -37,21 +21,11 @@ def ask(question, *args, **kwargs): yes = ('y', 'yes') prepend = '' if kwargs.get('nowhoami', False) else "{0}: ".format(whoami) try: - return raw_input("%s%s " % (prepend, - question % args)).strip().lower() in yes + return input("%s%s " % (prepend, + question % args)).strip().lower() in yes except KeyboardInterrupt: sys.exit(0) -def perror(message, **kwargs): - """ - Format an error message, log it in the debug log - and maybe also print it to stderr. - """ - should_print = kwargs.pop('_maybe', False) - msg = "{0}: {1}".format(whoami, message.format(**kwargs)) - logger.debug("Error: %s", msg) - if should_print: - print >>sys.stderr, msg def actually_delete(filename, options): """ @@ -67,7 +41,8 @@ def actually_delete(filename, options): filename): return False if options.noop: - print >>sys.stderr, "{0}: {1} would be removed".format(whoami, filename) + sys.stderr.write("{0}: {1} would be removed\n".format( + whoami, filename)) return True (dirname, basename) = os.path.split(filename) newname = os.path.join(dirname, '.#' + basename) @@ -85,6 +60,7 @@ def actually_delete(filename, options): os.utime(newname, None) return True + def delete(filename, options): logger.debug("delete(%s)", filename) if not os.path.lexists(filename): @@ -112,7 +88,8 @@ def delete(filename, options): is_empty = libdelete.empty_directory(filename) except OSError as e: # Do we want to only do this if emulating rm? - print >>sys.stderr, ": ".join((whoami, e.filename, e.strerror)) + sys.stderr.write("{}: {}: {}\n".format( + whoami, e.filename, e.strerror)) return False if is_empty: return actually_delete(filename, options) @@ -135,6 +112,7 @@ def delete(filename, options): else: return actually_delete(filename, options) + def main(): parser = optparse.OptionParser(usage="%prog [options] filename ...") # This is probably a terrible idea, but the old code did it @@ -167,8 +145,9 @@ def main(): default=linked_to_rmdir, help="Only remove empty directories (refuse to remove files)") parser.add_option( - "--debug", action="callback", type='string', help="Enable debugging (logger target or 'all')", - callback=debug_callback, metavar='target') + "--debug", action="callback", type='string', + help="Enable debugging (logger target or 'all')", + callback=libdelete.make_debug_callback(logger), metavar='target') (options, args) = parser.parse_args() if options.filesonly and options.directoriesonly: parser.error("-F and -D are mutually exclusive") @@ -181,13 +160,13 @@ def main(): for filename in args: # Because you know _someone_ will try it if len(filename.rstrip('/')) < 1: - print >>sys.stderr, "That's not a good idea." - sys.exit(1) + sys.exit("That's not a good idea.") # Trailing slashes make bad things happen if not delete(filename.rstrip('/'), options): errors = 1 return errors + if __name__ == "__main__": logging.basicConfig(level=logging.WARNING) sys.exit(main()) diff --git a/expunge b/expunge index 1e9e6b9..ac24346 100755 --- a/expunge +++ b/expunge @@ -6,6 +6,7 @@ import os import sys import libdelete +from libdelete import perror header = "The following deleted files are going to be expunged:\n" footer = """ @@ -17,20 +18,6 @@ confirmation = "Do you wish to continue [return = no]? " logger = logging.getLogger('expunge') whoami = os.path.basename(sys.argv[0]) -def debug_callback(option, opt_str, value, parser): - """ - An OptionParser callback that enables debugging. - """ - all_loggers = [logger.name, 'libdelete'] - loggers = [x.strip() for x in value.split(',')] - if value.lower() == 'all': - loggers = all_loggers - else: - if not set(loggers) == set(all_loggers): - parser.error('Valid debug targets: {0}'.format( - ", ".join(all_loggers))) - for l in loggers: - logging.getLogger(l).setLevel(logging.DEBUG) def ask(question, *args, **kwargs): """ @@ -40,35 +27,27 @@ def ask(question, *args, **kwargs): yes = ('y', 'yes') prepend = '' if kwargs.get('nowhoami', False) else "{0}: ".format(whoami) try: - return raw_input("%s%s " % (prepend, - question % args)).strip().lower() in yes + return input("%s%s " % (prepend, + question % args)).strip().lower() in yes except KeyboardInterrupt: sys.exit(0) -def perror(message, **kwargs): - """ - Format an error message, log it in the debug log - and maybe also print it to stderr. - """ - should_print = not kwargs.pop('_maybe', False) - msg = "{0}: {1}".format(whoami, message.format(**kwargs)) - logger.debug("Error: %s", msg) - if should_print: - print >>sys.stderr, msg def getsize(path): size = os.path.getsize(path) return (size, "(%dKB)" % (libdelete.to_kb(size),)) + def expunge(deleted_files, options): expunged_size = 0 errors = 0 if options.listfiles: - print header - print libdelete.format_columns(sorted( + print(header) + sys.stdout.write(libdelete.format_columns( + sorted( [libdelete.relpath( - libdelete.undeleted_name(x)) for x in deleted_files])) - print footer + libdelete.undeleted_name(x)) for x in deleted_files]))) + print(footer) if not options.force and \ not ask(confirmation, nowhoami=True): logger.debug("User failed to confirm; exiting") @@ -97,7 +76,11 @@ def expunge(deleted_files, options): # We exit here, not keep going, as the original code did sys.exit(errors) if options.verbose: - print "{whoami}: {path} {size} {maybe}expunged ({total}KB total)".format(whoami=whoami, path=f, size=size_str, maybe='would be ' if options.noop else '', total=libdelete.to_kb(expunged_size)) + print("{whoami}: {path} {size} {maybe}expunged " + "({total}KB total)".format( + whoami=whoami, path=f, size=size_str, + maybe='would be ' if options.noop else '', + total=libdelete.to_kb(expunged_size))) if not options.noop: if os.path.isdir(f) and not os.path.islink(f): logger.debug("rmdir: %s", f) @@ -107,9 +90,10 @@ def expunge(deleted_files, options): os.unlink(f) if options.yieldsize: - print "Total expunged: {0}KB".format(libdelete.to_kb(expunged_size)) + print("Total expunged: {0}KB".format(libdelete.to_kb(expunged_size))) return errors + def parse_options(): parser = optparse.OptionParser(usage="%prog [options] filename ...") parser.add_option( @@ -143,22 +127,23 @@ def parse_options(): "-m", dest="f_mounts", action="store_true", default=False, help="Follow mount points") parser.add_option( - "--debug", action="callback", type='string', help="Enable debugging (logger target or 'all')", - callback=debug_callback, metavar='target') + "--debug", action="callback", type='string', + help="Enable debugging (logger target or 'all')", + callback=libdelete.make_debug_callback(logger), metavar='target') (options, args) = parser.parse_args() if options.noop: # -n implies -v options.verbose = True return (options, args) + def main(): rv = 0 if ((whoami == "purge") and len(sys.argv) > 1): if (len(sys.argv) == 2) and (sys.argv[1] == '--debug'): sys.argv.append('all') else: - print >>sys.stderr, "purge does not take any arguments or options." - sys.exit(1) + sys.exit("purge does not take any arguments or options.") (options, args) = parse_options() if (whoami == "purge"): args = [os.path.expanduser('~')] @@ -185,11 +170,13 @@ def main(): # Doesn't cover all corner cases, but covers everything the old # code supported. In particular, weird symlinks will make this # sad - deleted_files.sort(reverse=True, key=lambda x: x.count(os.path.sep)) + deleted_files.sort(reverse=True, + key=lambda x: x.count(os.path.sep)) rv += expunge(deleted_files, options) return rv + if __name__ == "__main__": logging.basicConfig(level=logging.WARNING) sys.exit(main()) diff --git a/libdelete.py b/libdelete.py index 932fc98..3267611 100644 --- a/libdelete.py +++ b/libdelete.py @@ -8,29 +8,58 @@ import logging import os import re +import time import sys -import stat WILDCARDS_RE = re.compile('([*?[])') KILO = 1024 logger = logging.getLogger('libdelete') -have_AFS = True +_have_AFS = None +whoami = os.path.basename(sys.argv[0]) -try: - import afs.fs -except ImportError: - logger.warn("AFS support unavailable") - have_AFS = False class DeleteError(Exception): pass + +def make_debug_callback(logger): + def debug_callback(option, opt_str, value, parser): + """ + An OptionParser callback that enables debugging. + """ + all_loggers = [logger.name, 'libdelete'] + loggers = [x.strip() for x in value.split(',')] + if value.lower() == 'all': + loggers = all_loggers + else: + if not set(loggers) <= set(all_loggers): + parser.error('Valid debug targets: {0}'.format( + ", ".join(all_loggers))) + for l in loggers: + logging.getLogger(l).setLevel(logging.DEBUG) + + return debug_callback + + +def perror(message, **kwargs): + """ + Format an error message, log it in the debug log + and maybe also print it to stderr. + """ + should_print = kwargs.pop('_maybe', False) + msg = "{0}: {1}".format(whoami, message.format(**kwargs)) + logger.debug("Error: %s", msg) + if should_print: + sys.stderr.write("{}\n".format(msg)) + + def chunks(seq, size): """ Break a sequence up into size chunks """ - return (seq[pos:pos + size] for pos in xrange(0, len(seq), size)) + return (seq[pos:pos + size] for pos in range(0, len(seq), size)) + def format_columns(items, singlecol=False, width=80): """ @@ -50,12 +79,26 @@ def format_columns(items, singlecol=False, width=80): rv = [] for c in chunks(items, n_cols): rv.append("".join(item.ljust(col_width + padding) for item in c)) - return "\n".join(rv) + return "\n".join(rv) + "\n" if rv else "" + + +def have_AFS(): + global _have_AFS, afs + + if _have_AFS is None: + try: + import afs.fs + _have_AFS = True + except ImportError: + logger.warn("AFS support unavailable") + _have_AFS = False + return _have_AFS + def is_mountpoint(path): if os.path.ismount(path): return True - if have_AFS and afs.fs.inafs(os.path.abspath(path)): + if have_AFS() and afs.fs.inafs(os.path.abspath(path)): afs.fs.whichcell(path) try: return afs.fs.lsmount(path) is not None @@ -63,21 +106,25 @@ def is_mountpoint(path): logger.debug("Got exception while checking mount point: %s", e) return False + def has_wildcards(string): return WILDCARDS_RE.search(string) is not None + def is_deleted(path): """ Return True if the file has been 'deleted' by delete(1) """ return os.path.basename(path).startswith('.#') + def dir_listing(path): """ A directory listing with the full path. """ return [os.path.join(path, x) for x in os.listdir(path)] + def empty_directory(path): """ Return True if the directory is "empty" (that is, any entries @@ -85,6 +132,7 @@ def empty_directory(path): """ return all(is_deleted(x) for x in dir_listing(path)) + def relpath(path): """ For relative paths that begin with '.', strip off the leading @@ -92,6 +140,7 @@ def relpath(path): """ return path[2:] if path.startswith('./') else path + def undeleted_name(path): """ Return the undeleted name of a file. Only the last component @@ -105,6 +154,7 @@ def undeleted_name(path): else: return path + def n_days_old(path, n): if n < 0: raise ValueError("n must not be negative") @@ -115,27 +165,67 @@ def n_days_old(path, n): logger.debug("%s modified %d sec ago", path, mtime) return ((time.time() - mtime) >= (86400 * n)) + def escape_meta(path): return WILDCARDS_RE.sub(r'[\1]', path) + def to_kb(size): return int(round(float(size) / KILO)) + +class UniqueList(list): + def __add__(self, other): + new = UniqueList(self) + for i in other: + new.append(i) + return new + + def __radd__(self, other): + return self.__add__(other) + + def __iadd__(self, other): + for i in other: + self.append(i) + return self + + def append(self, item): + if item not in self: + super(UniqueList, self).append(item) + + def find_deleted_files(file_or_pattern, follow_links=False, follow_mounts=False, recurse_undeleted_subdirs=None, recurse_deleted_subdirs=None, n_days=0): - logger.debug("find_deleted_files(%s, links=%s, mounts=%s, recurse_un=%s, recurse_del=%s, ndays=%s)", - file_or_pattern, follow_links, follow_mounts, recurse_undeleted_subdirs, - recurse_deleted_subdirs, n_days) - rv = [] + logger.debug("find_deleted_files(%s, links=%s, mounts=%s, recurse_un=%s, " + "recurse_del=%s, ndays=%s)", + file_or_pattern, follow_links, follow_mounts, + recurse_undeleted_subdirs, recurse_deleted_subdirs, n_days) + # Support there is a file named ".#foo" and an undeleted file named "foo", + # and you type "lsdel foo". The code below will end up globbing both "foo" + # and ".#foo", then turning "foo" into ".#foo" and adding it twice. Ditto + # if there are both deleted and undeleted directories with the same name. + # The cleanest way to work around this is to uniquify deleted items as we + # add them to our return list. + rv = UniqueList() # In AFS, without tokens, this is very slow. "Don't do that." # The old code called readdir() and lstat'd everything before following. - # The old code also re-implemented glob() with BREs, and we're not doing that. - file_list = glob.glob(file_or_pattern) + glob.glob('.#' + file_or_pattern) + # The old code also re-implemented glob() with BREs, and we're not doing + # that. + file_list = glob.glob(file_or_pattern) + if os.sep not in file_or_pattern: + file_list += glob.glob('.#' + file_or_pattern) + else: + file_list += glob.glob(os.path.join( + os.path.dirname(file_or_pattern), '.#' + + os.path.basename(file_or_pattern))) + if len(file_list) == 0: - raise DeleteError("{0}: {1}".format(file_or_pattern, - "No match" if has_wildcards(file_or_pattern) else os.strerror(errno.ENOENT))) + raise DeleteError("{0}: {1}".format( + file_or_pattern, + "No match" if has_wildcards(file_or_pattern) + else os.strerror(errno.ENOENT))) for filename in file_list: logger.debug("Examining %s", filename) @@ -147,10 +237,12 @@ def find_deleted_files(file_or_pattern, follow_links=False, if is_mountpoint(filename) and not follow_mounts: logger.debug("Skipping mountpoint: %s", filename) continue - if ((is_deleted(filename) and (recurse_deleted_subdirs != False)) or \ - (not is_deleted(filename) and (recurse_undeleted_subdirs != False))): - # NOTE: recurse_undeleted_subdirs is being abused as a tristate with 'None' - # meaning "do it on the first time only. + if ((is_deleted(filename) and + (recurse_deleted_subdirs is not False)) or + (not is_deleted(filename) and + (recurse_undeleted_subdirs is not False))): + # NOTE: recurse_undeleted_subdirs is being abused as a tristate + # with 'None' meaning "do it on the first time only. logger.debug("Recursing into %sdeleted directory: %s", "un" if not is_deleted(filename) else "", filename) @@ -158,10 +250,13 @@ def find_deleted_files(file_or_pattern, follow_links=False, for item in dir_listing(filename): # Escape metachars before recursing because filenames # can in fact contain metacharacters. - rv += find_deleted_files(escape_meta(item), follow_links, follow_mounts, - False if recurse_undeleted_subdirs is None else recurse_undeleted_subdirs, - False if recurse_deleted_subdirs is None else recurse_deleted_subdirs, - n_days) + rv += find_deleted_files( + escape_meta(item), follow_links, follow_mounts, + False if recurse_undeleted_subdirs is None + else recurse_undeleted_subdirs, + False if recurse_deleted_subdirs is None + else recurse_deleted_subdirs, + n_days) except OSError as e: perror('{filename}: {error}', filename=e.filename, error=e.strerror) diff --git a/lsdel b/lsdel index 2d0a9de..6f522e1 100755 --- a/lsdel +++ b/lsdel @@ -6,35 +6,11 @@ import os import sys import libdelete +from libdelete import perror logger = logging.getLogger('lsdel') whoami = os.path.basename(sys.argv[0]) -def debug_callback(option, opt_str, value, parser): - """ - An OptionParser callback that enables debugging. - """ - all_loggers = [logger.name, 'libdelete'] - loggers = [x.strip() for x in value.split(',')] - if value.lower() == 'all': - loggers = all_loggers - else: - if not set(loggers) == set(all_loggers): - parser.error('Valid debug targets: {0}'.format( - ", ".join(all_loggers))) - for l in loggers: - logging.getLogger(l).setLevel(logging.DEBUG) - -def perror(message, **kwargs): - """ - Format an error message, log it in the debug log - and maybe also print it to stderr. - """ - should_print = not kwargs.pop('_maybe', False) - msg = "{0}: {1}".format(whoami, message.format(**kwargs)) - logger.debug("Error: %s", msg) - if should_print: - print >>sys.stderr, msg def parse_options(): parser = optparse.OptionParser(usage="%prog [options] filename ...") @@ -63,8 +39,9 @@ def parse_options(): "-m", dest="f_mounts", action="store_true", default=False, help="Follow mount points") parser.add_option( - "--debug", action="callback", type='string', help="Enable debugging (logger target or 'all')", - callback=debug_callback, metavar='target') + "--debug", action="callback", type='string', + help="Enable debugging (logger target or 'all')", + callback=libdelete.make_debug_callback(logger), metavar='target') (options, args) = parser.parse_args() if options.singlecolumn and options.multicolumn: parser.error("-C and -1 are mutually exclusive") @@ -74,6 +51,7 @@ def parse_options(): delattr(options, 'multicolumn') return (options, args) + def main(): rv = 0 (options, args) = parse_options() @@ -87,16 +65,18 @@ def main(): follow_links=options.f_links, follow_mounts=options.f_mounts, recurse_undeleted_subdirs=options.recursive or None, - recurse_deleted_subdirs= not options.dirsonly, + recurse_deleted_subdirs=not options.dirsonly, n_days=options.timev) except libdelete.DeleteError as e: perror(e.message) rv = 1 - print libdelete.format_columns(sorted( + sys.stdout.write(libdelete.format_columns( + sorted( [libdelete.relpath( - libdelete.undeleted_name(x)) for x in deleted_files]), - options.singlecolumn) + libdelete.undeleted_name(x)) for x in deleted_files]), + options.singlecolumn)) + if options.yieldsize: total = None try: @@ -107,11 +87,14 @@ def main(): rv = 1 if total is None: - perror('Unable to display total size: errors occurred during calculation.') + perror('Unable to display total size: errors occurred during ' + 'calculation.') else: - print "\nTotal space taken up by files: %dKB" % round((float(total) / 1024)) + print("\nTotal space taken up by files: %dKB" % + round((float(total) / 1024))) return rv + if __name__ == "__main__": logging.basicConfig(level=logging.WARNING) sys.exit(main()) diff --git a/undelete b/undelete index e3ba7ac..20cdd85 100755 --- a/undelete +++ b/undelete @@ -4,28 +4,14 @@ import logging import optparse import os import shutil -import stat import sys import libdelete +from libdelete import perror logger = logging.getLogger('undelete') whoami = os.path.basename(sys.argv[0]) -def debug_callback(option, opt_str, value, parser): - """ - An OptionParser callback that enables debugging. - """ - all_loggers = [logger.name, 'libdelete'] - loggers = [x.strip() for x in value.split(',')] - if value.lower() == 'all': - loggers = all_loggers - else: - if not set(loggers) == set(all_loggers): - parser.error('Valid debug targets: {0}'.format( - ", ".join(all_loggers))) - for l in loggers: - logging.getLogger(l).setLevel(logging.DEBUG) def ask(question, *args, **kwargs): """ @@ -35,28 +21,20 @@ def ask(question, *args, **kwargs): yes = ('y', 'yes') prepend = '' if kwargs.get('nowhoami', False) else "{0}: ".format(whoami) try: - return raw_input("%s%s " % (prepend, - question % args)).strip().lower() in yes + return input("%s%s " % (prepend, + question % args)).strip().lower() in yes except KeyboardInterrupt: sys.exit(0) -def perror(message, **kwargs): - """ - Format an error message, log it in the debug log - and maybe also print it to stderr. - """ - should_print = kwargs.pop('_maybe', False) - msg = "{0}: {1}".format(whoami, message.format(**kwargs)) - logger.debug("Error: %s", msg) - if should_print: - print >>sys.stderr, msg def actually_undelete(filename, options): undeleted_name = libdelete.undeleted_name(filename) logger.debug("actually_undelete(%s)", filename) logger.debug("undeleted name: %s", undeleted_name) - if options.interactive and not ask('Undelete %s%s?', 'directory' if os.path.isdir(filename) else '', filename): + if options.interactive and not ask( + 'Undelete %s%s?', 'directory' if os.path.isdir(filename) else '', + filename): return False if os.path.exists(undeleted_name): if not options.force: @@ -65,16 +43,18 @@ def actually_undelete(filename, options): if os.path.isdir(undeleted_name) and \ not os.path.islink(undeleted_name) and \ not libdelete.is_mountpoint(undeleted_name): - shutil.rmtree(undleted_name) + shutil.rmtree(undeleted_name) else: os.unlink(undeleted_name) if options.noop: - print >>sys.stderr, "{0}: {1} would be undeleted".format(whoami, filename) + sys.stderr.write("{0}: {1} would be undeleted\n".format( + whoami, filename)) return True os.rename(filename, undeleted_name) - print >>sys.stderr, "{0}: {1} undeleted".format(whoami, filename) + sys.stderr.write("{0}: {1} undeleted\n".format(whoami, filename)) return True + def undelete(filename, options): r_undel = None if options.dirsonly: @@ -90,7 +70,8 @@ def undelete(filename, options): recurse_deleted_subdirs=r_del, follow_links=True, follow_mounts=True) if len(deleted_files) == 0: - perror("{0}: No such file or directory".format(filename), _maybe=options.report_errors) + perror("{0}: No such file or directory".format(filename), + _maybe=options.report_errors) return False deleted_files.sort(reverse=True, key=lambda x: x.count(os.path.sep)) for f in deleted_files: @@ -101,11 +82,11 @@ def undelete(filename, options): def get_filenames_from_stdin(options): errors = 0 if options.verbose: - print "Enter the files to be undeleted, one file per line." - print "Hit on a line by itself to exit.\n" + print("Enter the files to be undeleted, one file per line.") + print("Hit on a line by itself to exit.\n") while True: try: - filename = raw_input("{0}: ".format(whoami)) + filename = input("{0}: ".format(whoami)) except (EOFError, KeyboardInterrupt): sys.exit(errors) if len(filename) == 0: @@ -118,6 +99,7 @@ def get_filenames_from_stdin(options): # Control should never get here, but just for good measure sys.exit(errors) + def main(): rv = 0 parser = optparse.OptionParser(usage="%prog [options] filename ...") @@ -140,8 +122,9 @@ def main(): "-R", dest="dirsonly", action="store_true", default=False, help="directories only (no recursion)") parser.add_option( - "--debug", action="callback", type='string', help="Enable debugging (logger target or 'all')", - callback=debug_callback, metavar='target') + "--debug", action="callback", type='string', + help="Enable debugging (logger target or 'all')", + callback=libdelete.make_debug_callback(logger), metavar='target') (options, args) = parser.parse_args() if options.recursive and options.dirsonly: parser.error("-r and -R are mutually exclusive") @@ -156,6 +139,7 @@ def main(): rv = 1 return rv + if __name__ == "__main__": logging.basicConfig(level=logging.WARNING) sys.exit(main())