Last active
December 27, 2022 03:10
-
-
Save jaytaylor/ea1b6082ea5bcea7a6b65518d91238f5 to your computer and use it in GitHub Desktop.
Removes a file or directory while preserving the modification time (mtime) of the parent directory.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python | |
# -*- coding: utf-8 -*- | |
""" | |
Removes a file or directory while preserving the modification time (mtime) of | |
the parent directory. | |
Pure-python implementation. | |
See also: | |
https://unix.stackexchange.com/a/565595/22709 | |
""" | |
import argparse | |
import logging | |
import os | |
import platform | |
import re | |
import shutil | |
import stat | |
import sys | |
log_formatter = logging.Formatter('%(asctime)s, %(levelname)s %(message)s') | |
logging.basicConfig(level=logging.INFO, formatter=log_formatter) | |
logger = logging.getLogger(__name__) | |
def parse_opts(args): | |
parser = argparse.ArgumentParser( | |
description='Removes a file or directory while preserving the modification time (mtime) of the parent directory.', | |
argument_default=False, | |
) | |
parser.add_argument('-f', '--force', default=False, action='store_true', help='Ignore nonexistent files and arguments') | |
parser.add_argument('-i', '--interactive', default=False, action='store_true', help='Prompt for confirmation before taking action on a target') | |
parser.add_argument('--no-preserve-root', default=False, action='store_true', help="Do not treat '/' specially") | |
parser.add_argument('-r', '--recursive', default=False, action='store_true', help='Remove directories and their contents recursively') | |
parser.add_argument('-v', '--verbose', default=False, action='store_true', help='Display verbose log output') | |
parser.add_argument('paths', type=str, nargs='+', help='Filesystem path(s) to remove') | |
opts = parser.parse_args(args) | |
if opts.verbose: | |
logger.setLevel(logging.DEBUG) | |
return opts | |
# n.b. Use the appropriate input function depending on whether the runtime | |
# environment is Python version 2 or 3. | |
_input_fn = input if sys.version_info > (3, 0) else raw_input | |
def _prompt_for_confirmation(path, opts): | |
if not opts.interactive: | |
return True | |
response = _input_fn('Permanently remove "%s"? (Y/n)' % (path,)) | |
return response in ('Y', 'y') | |
def require_write_permissions(path): | |
parent = os.path.dirname(path) | |
if not os.access(parent, os.W_OK): | |
raise Exception('Missing necessary write permission for parent directory "%s"; operation aborted' % (parent,)) | |
def rm_preserving_parent_mtime(path, opts): | |
""" | |
Deletes the specified path, and restores the filesystem access and modified | |
timestamp values afterward removing path. | |
IMPORTANT | |
--------- | |
Take note of the permission test before removing the target. These checks | |
verify write access to parent's parent. | |
Without the check, there is a risk that the file will be removed and then | |
setting mtime fails. When this happens, the entire purpose of this program is defeated. | |
""" | |
if not opts.no_preserve_root and (path == os.sep or (platform.system() == 'Windows' and re.match(r'^[A-Z]:%s?' % (os.sep,), path))): | |
raise Exception('Cowardly refusing to operate on root path') | |
if path in ('', '.', '..'): | |
raise Exception('Invalid path "%s", must have a parent directory' % (path,)) | |
parent = os.path.dirname(path) | |
if path == parent: | |
raise Exception('Invalid path, parent directory="%s" should not equal path="%s"' % (parent, path)) | |
st = os.stat(parent) | |
atime = st[stat.ST_ATIME] | |
mtime = st[stat.ST_MTIME] | |
modified = False | |
try: | |
if os.path.isfile(path): | |
require_write_permissions(parent) | |
if _prompt_for_confirmation(path, opts): | |
logger.debug('Removing file "%s"' % (path,)) | |
modified = True | |
os.remove(path) | |
elif os.path.isdir(path): | |
if not opts.recursive: | |
raise Exception('Cannot remove "%s": Is a directory' % (path,)) | |
require_write_permissions(parent) | |
if _prompt_for_confirmation(path, opts): | |
logger.debug('Removing directory "%s"' % (path,)) | |
modified = True | |
shutil.rmtree(path) | |
else: | |
raise Exception('Path "%s" is not a file or directory' % (path,)) | |
finally: | |
if modified: | |
logger.debug('Restoring access and modification timestamps for parent="%s"' % (parent,)) | |
os.utime(parent, (atime, mtime)) | |
def main(paths, opts): | |
try: | |
for path in paths: | |
rm_preserving_parent_mtime(path, opts) | |
return 0 | |
except BaseException: | |
logger.exception('Caught exception in main') | |
if opts.force: | |
return 0 | |
return 1 | |
if __name__ == '__main__': | |
opts = parse_opts(sys.argv[1:]) | |
sys.exit(main(opts.paths, opts)) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment