[28546] in Source-Commits
delete commit: Initial checkin of Python implementation
daemon@ATHENA.MIT.EDU (Victor Vasiliev)
Sat Nov 28 17:03:26 2015
Date: Sat, 28 Nov 2015 17:03:24 -0500
From: Victor Vasiliev <vasilvv@mit.edu>
Message-Id: <201511282203.tASM3OiE019110@drugstore.mit.edu>
To: source-commits@mit.edu
https://github.com/mit-athena/delete/commit/0a05908e3b1c2ada6620b4900b8994c4d6da3d3a
commit 0a05908e3b1c2ada6620b4900b8994c4d6da3d3a
Author: Jonathan Reed <jdreed@mit.edu>
Date: Fri Feb 21 12:24:45 2014 -0500
Initial checkin of Python implementation
delete | 191 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
expunge | 195 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
libdelete.py | 182 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
lsdel | 117 +++++++++++++++++++++++++++++++++++
setup.py | 8 +++
undelete | 161 ++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 854 insertions(+), 0 deletions(-)
diff --git a/delete b/delete
new file mode 100755
index 0000000..91a4361
--- /dev/null
+++ b/delete
@@ -0,0 +1,191 @@
+#!/usr/bin/python
+
+import logging
+import optparse
+import os
+import shutil
+import stat
+import sys
+
+import afs.fs
+
+logger = logging.getLogger('delete')
+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):
+ """
+ Ask a question, possibly prepended with the name of the program
+ and determine whether the user answered in the affirmative
+ """
+ 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
+ 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 actually_delete(filename, options):
+ """
+ Actually delete the file.
+ """
+ logger.debug("actually_delete(%s)", filename)
+ if options.interactive and not ask('remove %s?', filename):
+ return False
+ if not options.force and \
+ not os.path.islink(filename) and \
+ not os.access(filename, os.W_OK):
+ if not ask("File %s not writeable. Delete anyway?",
+ filename):
+ return False
+ if options.noop:
+ print >>sys.stderr, "{0}: {1} would be removed".format(whoami, filename)
+ return True
+ (dirname, basename) = os.path.split(filename)
+ newname = os.path.join(dirname, '.#' + basename)
+ if os.path.exists(newname):
+ # Yes, it just unconditionally scribbles over things, and always has.
+ if os.path.isdir(newname) and not os.path.islink(newname):
+ shutil.rmtree(newname)
+ else:
+ os.path.unlink(newname)
+ assert not os.path.exists(newname), "Fatal error: new path exists"
+ logger.debug("Move %s to %s", filename, newname)
+ # Maybe we can just use os.rename here
+ shutil.move(filename, newname)
+ # None means use the current time
+ os.utime(newname, None)
+ return True
+
+def delete(filename, options):
+ logger.debug("delete(%s)", filename)
+ if not os.path.lexists(filename):
+ perror('{filename}: No such file or directory',
+ filename=filename, _maybe=options.report_errors)
+ return False
+ if os.path.isdir(filename) and not os.path.islink(filename):
+ if os.path.basename(filename) in ('.', '..'):
+ perror("Cannot delete '.' or '..'", _maybe=options.report_errors)
+ return False
+ if options.filesonly:
+ if not options.recursive:
+ perror("{filename}: can't delete (not file)",
+ filename=filename, _maybe=options.report_errors)
+ return False
+ for x in libdelete.dir_listing(filename):
+ logger.debug("Recursively deleting %s", x)
+ if not delete(x, options):
+ logger.debug("Recursive delete failed")
+ return False
+ return actually_delete(x, options)
+
+ else:
+ try:
+ 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))
+ return False
+ if is_empty:
+ return actually_delete(filename, options)
+ if options.directoriesonly or not options.recursive:
+ perror("{filename}: can't delete (directory not empty)",
+ filename=filename, _maybe=options.report_errors)
+ elif options.recursive:
+ for x in libdelete.dir_listing(filename):
+ logger.debug("Recursively deleting %s", x)
+ if not delete(x, options):
+ logger.debug("Recursively delete failed")
+ return False
+ return actually_delete(filename, options)
+
+ # Not a directory
+ else:
+ if options.directoriesonly:
+ perror("{filename}: can't delete (not directory)",
+ filename=filename, _maybe=options.report_errors)
+ 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
+ linked_to_rm = whoami == "rm"
+ linked_to_rmdir = whoami == "rmdir"
+ parser.add_option(
+ "-r", dest="recursive", action="store_true", default=False,
+ help="Recursively delete non-empty directories")
+ parser.add_option(
+ "-f", dest="force", action="store_true", default=False,
+ help="Do not ask questions or report errors about nonexistent files")
+ parser.add_option(
+ "-i", dest="interactive", action="store_true", default=False,
+ help="Prompt for confirmation before deleting each file/directory")
+ parser.add_option(
+ "-n", dest="noop", action="store_true", default=False,
+ help="Don't actually delete, just print what would be deleted")
+ parser.add_option(
+ "-v", dest="verbose", action="store_true", default=False,
+ help="Print each filename as it is deleted")
+ parser.add_option(
+ "-e", dest="emulate_rm", action="store_true",
+ default=(linked_to_rm or linked_to_rmdir),
+ help="Emulate the pecularities of rm and rmdir")
+ parser.add_option(
+ "-F", dest="filesonly", action="store_true", default=linked_to_rm,
+ help="Remove files only (refuse to remove even non-empty directories)")
+ parser.add_option(
+ "-D", dest="directoriesonly", action="store_true",
+ 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')
+ (options, args) = parser.parse_args()
+ if options.filesonly and options.directoriesonly:
+ parser.error("-F and -D are mutually exclusive")
+ if options.recursive and options.directoriesonly:
+ parser.error("-r and -D are mutually exclusive")
+ if len(args) < 1:
+ parser.error("No files or directories specified.")
+ options.report_errors = not options.emulate_rm or not options.force
+ errors = 0
+ 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)
+ # 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
new file mode 100755
index 0000000..1e9e6b9
--- /dev/null
+++ b/expunge
@@ -0,0 +1,195 @@
+#!/usr/bin/python
+
+import logging
+import optparse
+import os
+import sys
+
+import libdelete
+
+header = "The following deleted files are going to be expunged:\n"
+footer = """
+The above files, which have been marked for deletion, are about to be
+expunged forever! Make sure you don't need any of them before continuing.
+"""
+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):
+ """
+ Ask a question, possibly prepended with the name of the program
+ and determine whether the user answered in the affirmative
+ """
+ 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
+ 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(
+ [libdelete.relpath(
+ 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")
+ sys.exit(0)
+ for f in deleted_files:
+ logger.debug("Processing %s", f)
+ real_name = libdelete.relpath(libdelete.undeleted_name(f))
+ logger.debug("Undeleted name: %s", real_name)
+ if options.interactive or options.yieldsize or options.verbose:
+ size, size_str = (0, '(??KB)')
+ try:
+ size, size_str = getsize(f)
+ except OSError as e:
+ perror('{filename}: {error} while getting size',
+ filename=e.filename, error=e.strerror)
+ errors = 1
+ logger.debug("Size, size_str = %s, %s", size, size_str)
+ expunged_size += size
+ if options.interactive:
+ filetype = 'directory' if os.path.isdir(f) else ''
+ # This is correct. We do not display sizes of directories
+ # when prompting, but do display them below. Because why not.
+ if not ask('Expunge %s%s%s?', filetype, real_name,
+ '' if os.path.isdir(f) else size_str):
+ logger.debug("User failed to confirm, exiting...")
+ # 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))
+ if not options.noop:
+ if os.path.isdir(f) and not os.path.islink(f):
+ logger.debug("rmdir: %s", f)
+ os.rmdir(f)
+ else:
+ logger.debug("unlink: %s", f)
+ os.unlink(f)
+
+ if options.yieldsize:
+ 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(
+ "-l", dest="listfiles", action="store_true", default=False,
+ help="List files before expunging")
+ parser.add_option(
+ "-r", dest="recursive", action="store_true", default=False,
+ help="Recursively delete non-empty directories")
+ parser.add_option(
+ "-f", dest="force", action="store_true", default=False,
+ help="Do not ask questions or report errors about nonexistent files")
+ parser.add_option(
+ "-i", dest="interactive", action="store_true", default=False,
+ help="Prompt for confirmation before deleting each file/directory")
+ parser.add_option(
+ "-n", dest="noop", action="store_true", default=False,
+ help="Don't actually delete, just print what would be deleted")
+ parser.add_option(
+ "-v", dest="verbose", action="store_true", default=False,
+ help="Print each filename as it is deleted")
+ parser.add_option(
+ "-t", dest="timev", action="store", type="int", default=0,
+ help="Only list n-day-or-older files", metavar="n")
+ parser.add_option(
+ "-y", dest="yieldsize", action="store_true", default=False,
+ help="Report total space taken up by files")
+ parser.add_option(
+ "-s", dest="f_links", action="store_true", default=False,
+ help="Follow symlinks to directories")
+ parser.add_option(
+ "-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')
+ (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)
+ (options, args) = parse_options()
+ if (whoami == "purge"):
+ args = [os.path.expanduser('~')]
+ options.recursive = True
+ options.listfiles = True
+ if len(args) < 1:
+ args.append('.')
+ deleted_files = []
+ for filename in args:
+ try:
+ deleted_files += libdelete.find_deleted_files(
+ filename,
+ follow_links=options.f_links,
+ follow_mounts=options.f_mounts,
+ recurse_undeleted_subdirs=options.recursive or None,
+ recurse_deleted_subdirs=True,
+ n_days=options.timev)
+ except libdelete.DeleteError as e:
+ perror(e.message)
+ rv = 1
+ logger.debug("Found %d files", len(deleted_files))
+ if len(deleted_files):
+ # Sort them so we're deleting leaves first
+ # 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))
+ 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
new file mode 100644
index 0000000..c66cd17
--- /dev/null
+++ b/libdelete.py
@@ -0,0 +1,182 @@
+"""
+Library and helper functions for the delete(1) suite of tools, including
+delete, expunge, lsdel, purge, and undelete.
+"""
+
+import errno
+import glob
+import logging
+import os
+import re
+import sys
+import stat
+
+WILDCARDS_RE = re.compile('([*?[])')
+KILO = 1024
+
+logger = logging.getLogger('libdelete')
+have_AFS = True
+
+try:
+ import afs.fs
+except ImportError:
+ logger.warn("AFS support unavailable")
+ have_AFS = False
+
+class DeleteError(Exception):
+ pass
+
+def chunks(seq, size):
+ """
+ Break a sequence up into size chunks
+ """
+ return (seq[pos:pos + size] for pos in xrange(0, len(seq), size))
+
+def format_columns(items, singlecol=False, width=80):
+ """
+ Pretty-print in optional multi-column format, with padding/spread.
+ """
+ if singlecol:
+ return "\n".join(items)
+ # The old code printed column-first, not row-first.
+ # I can't be convinced to care.
+ if len(items) < 1:
+ return ""
+ col_width = max(len(x) for x in items) + 2
+ if col_width > width:
+ return "\n".join(items)
+ n_cols = width // col_width
+ padding = (80 - (n_cols * col_width)) // n_cols
+ rv = []
+ for c in chunks(items, n_cols):
+ rv.append("".join(item.ljust(col_width + padding) for item in c))
+ return "\n".join(rv)
+
+def is_mountpoint(path):
+ if os.path.ismount(path):
+ return True
+ if have_AFS and afs.fs.inafs(os.path.abspath(path)):
+ afs.fs.whichcell(path)
+ print "afs.fs.inafs %s", path
+ print afs.fs.inafs(os.path.abspath(path))
+ try:
+ return afs.fs.lsmount(path) is not None
+ except OSError as e:
+ 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
+ in it have been deleted)
+ """
+ return all(is_deleted(x) for x in dir_listing(path))
+
+def relpath(path):
+ """
+ For relative paths that begin with '.', strip off the leading
+ stuff.
+ """
+ return path[2:] if path.startswith('./') else path
+
+def undeleted_name(path):
+ """
+ Return the undeleted name of a file. Only the last component
+ is changed. If it's in a chain of deleted directories, those
+ are still printed with the leading '.#' for compatibility.
+ """
+ parts = os.path.split(path)
+ if parts[1].startswith('.#'):
+ return os.path.join(parts[0],
+ parts[1][2:])
+ else:
+ return path
+
+def n_days_old(path, n):
+ if n < 0:
+ raise ValueError("n must not be negative")
+ if n == 0:
+ # All extant files are, by definition, 0 days old
+ return True
+ mtime = os.path.getmtime(path)
+ 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))
+
+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 = []
+ # 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)
+ 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)))
+
+ for filename in file_list:
+ logger.debug("Examining %s", filename)
+ if os.path.isdir(filename):
+ logger.debug("%s is a directory", filename)
+ if os.path.islink(filename) and not follow_links:
+ logger.debug("Skipping symlink: %s", filename)
+ continue
+ 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.
+ logger.debug("Recursing into %sdeleted directory: %s",
+ "un" if not is_deleted(filename) else "",
+ filename)
+ try:
+ 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)
+ except OSError as e:
+ perror('{filename}: {error}', filename=e.filename,
+ error=e.strerror)
+ if is_deleted(filename):
+ try:
+ if not n_days_old(filename, n_days):
+ logger.debug("%s is not %d days old, skipping",
+ filename, n_days)
+ continue
+ except OSError as e:
+ perror('{filename}: {error} while checking age',
+ filename=e.filename, error=e.strerror)
+ logger.debug("Adding: %s", filename)
+ rv.append(filename)
+
+ return rv
diff --git a/lsdel b/lsdel
new file mode 100755
index 0000000..2d0a9de
--- /dev/null
+++ b/lsdel
@@ -0,0 +1,117 @@
+#!/usr/bin/python
+
+import logging
+import optparse
+import os
+import sys
+
+import libdelete
+
+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 ...")
+ parser.add_option(
+ "-1", dest="singlecolumn", action="store_true", default=False,
+ help="Force single-column output")
+ parser.add_option(
+ "-C", dest="multicolumn", action="store_true", default=False,
+ help="Force multicolumn output (default when stdout is a tty)")
+ parser.add_option(
+ "-d", dest="dirsonly", action="store_true", default=False,
+ help="List directory names, not contents")
+ parser.add_option(
+ "-r", dest="recursive", action="store_true", default=False,
+ help="recursive")
+ parser.add_option(
+ "-t", dest="timev", action="store", type="int", default=0,
+ help="Only list n-day-or-older files", metavar="n")
+ parser.add_option(
+ "-y", dest="yieldsize", action="store_true", default=False,
+ help="Report total space taken up by files")
+ parser.add_option(
+ "-s", dest="f_links", action="store_true", default=False,
+ help="Follow symlinks to directories")
+ parser.add_option(
+ "-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')
+ (options, args) = parser.parse_args()
+ if options.singlecolumn and options.multicolumn:
+ parser.error("-C and -1 are mutually exclusive")
+ if not options.singlecolumn and not options.multicolumn:
+ options.singlecolumn = not sys.stdout.isatty()
+ # We really only need to this for mutual exclusivity
+ delattr(options, 'multicolumn')
+ return (options, args)
+
+def main():
+ rv = 0
+ (options, args) = parse_options()
+ if len(args) < 1:
+ args.append('.')
+ deleted_files = []
+ for filename in args:
+ try:
+ deleted_files += libdelete.find_deleted_files(
+ filename,
+ follow_links=options.f_links,
+ follow_mounts=options.f_mounts,
+ recurse_undeleted_subdirs=options.recursive or None,
+ 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(
+ [libdelete.relpath(
+ libdelete.undeleted_name(x)) for x in deleted_files]),
+ options.singlecolumn)
+ if options.yieldsize:
+ total = None
+ try:
+ total = sum([os.path.getsize(x) for x in deleted_files])
+ except OSError as e:
+ perror('{filename}: {error} while getting size',
+ filename=e.filename, error=e.strerror)
+ rv = 1
+
+ if total is None:
+ perror('Unable to display total size: errors occurred during calculation.')
+ else:
+ 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/setup.py b/setup.py
new file mode 100644
index 0000000..8332792
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,8 @@
+from distutils.core import setup
+
+setup(name='delete',
+ version='10.1',
+ author='Debathena Project',
+ author_email='debathena@mit.edu',
+ scripts=['delete', 'undelete', 'lsdel', 'expunge'],
+ py_modules['libdelete.py'])
diff --git a/undelete b/undelete
new file mode 100755
index 0000000..d63d260
--- /dev/null
+++ b/undelete
@@ -0,0 +1,161 @@
+#!/usr/bin/python
+
+import logging
+import optparse
+import os
+import shutil
+import stat
+import sys
+
+import libdelete
+
+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):
+ """
+ Ask a question, possibly prepended with the name of the program
+ and determine whether the user answered in the affirmative
+ """
+ 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
+ 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 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):
+ return False
+ if os.path.exists(undeleted_name):
+ if not options.force:
+ if not ask('Undeleted %s exists. Remove it?', undeleted_name):
+ return False
+ if os.path.isdir(undeleted_name) and \
+ not os.path.islink(undeleted_name) and \
+ not libdelete.is_mountpoint(undeleted_name):
+ shutil.rmtree(undleted_name)
+ else:
+ os.unlink(undeleted_name)
+ if options.noop:
+ print >>sys.stderr, "{0}: {1} would be undeleted".format(whoami, filename)
+ return True
+ os.rename(filename, undeleted_name)
+ print >>sys.stderr, "{0}: {1} undeleted".format(whoami, filename)
+ return True
+
+def undelete(filename, options):
+ r_undel = None
+ if options.dirsonly:
+ r_undel = False
+ r_del = None
+ if options.recursive:
+ r_del = True
+ if options.dirsonly:
+ r_del = False
+ deleted_files = libdelete.find_deleted_files(
+ filename,
+ recurse_undeleted_subdirs=r_undel,
+ 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)
+ return False
+ deleted_files.sort(reverse=True, key=lambda x: x.count(os.path.sep))
+ for f in deleted_files:
+ actually_undelete(f, options)
+ return True
+
+
+def get_filenames_from_stdin(options):
+ errors = 0
+ if options.verbose:
+ print "Enter the files to be undeleted, one file per line."
+ print "Hit <RETURN> on a line by itself to exit.\n"
+ while True:
+ try:
+ filename = raw_input("{0}: ".format(whoami))
+ except (EOFError, KeyboardInterrupt):
+ sys.exit(errors)
+ if len(filename) == 0:
+ sys.exit(errors)
+ try:
+ undelete(filename, options)
+ except libdelete.DeleteError as e:
+ perror(e.message, _maybe=options.report_errors)
+ errors = 1
+ # Control should never get here, but just for good measure
+ sys.exit(errors)
+
+def main():
+ rv = 0
+ parser = optparse.OptionParser(usage="%prog [options] filename ...")
+ parser.add_option(
+ "-r", dest="recursive", action="store_true", default=False,
+ help="Recursively delete non-empty directories")
+ parser.add_option(
+ "-f", dest="force", action="store_true", default=False,
+ help="Do not ask questions or report errors about nonexistent files")
+ parser.add_option(
+ "-i", dest="interactive", action="store_true", default=False,
+ help="Prompt for confirmation before deleting each file/directory")
+ parser.add_option(
+ "-n", dest="noop", action="store_true", default=False,
+ help="Don't actually delete, just print what would be deleted")
+ parser.add_option(
+ "-v", dest="verbose", action="store_true", default=False,
+ help="Print each filename as it is deleted")
+ parser.add_option(
+ "-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')
+ (options, args) = parser.parse_args()
+ if options.recursive and options.dirsonly:
+ parser.error("-r and -R are mutually exclusive")
+ options.report_errors = not options.force
+ if len(args) < 1:
+ get_filenames_from_stdin(options)
+ for filename in args:
+ try:
+ undelete(filename, options)
+ except libdelete.DeleteError as e:
+ perror(e.message, _maybe=options.report_errors)
+ rv = 1
+ return rv
+
+if __name__ == "__main__":
+ logging.basicConfig(level=logging.WARNING)
+ sys.exit(main())