[28546] in Source-Commits

home help back first fref pref prev next nref lref last post

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())

home help back first fref pref prev next nref lref last post