|
#!/usr/bin/env python3 |
|
# -*- coding: utf-8 -*- |
|
|
|
""" |
|
Renames a file or directory while preserving the modification times (mtimes) of |
|
the source and destination parent directories. |
|
|
|
Based on rm_preserving_mtime.py. |
|
|
|
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 |
|
|
|
formatter = logging.Formatter('%(asctime)s, %(levelname)s %(message)s') |
|
|
|
ch = logging.StreamHandler() |
|
ch.setFormatter(formatter) |
|
logger = logging.getLogger(__name__) |
|
logger.addHandler(ch) |
|
logger.setLevel(logging.INFO) |
|
|
|
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('-d', '--dry-run', default=False, action='store_true', help='Dry-run mode, will not write out any changes to disk') |
|
parser.add_argument('-f', '--force', default=False, action='store_true', help='Ignore non-existent 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('-v', '--verbose', default=False, action='store_true', help='Display verbose log output') |
|
parser.add_argument('src', type=str, help='Source location') |
|
parser.add_argument('dest', type=str, help='Destination location') |
|
|
|
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(src, dest, opts): |
|
if not opts.interactive: |
|
return True |
|
|
|
response = _input_fn('Relocate "%s" to "%s"? (Y/n)' % (src, dest,)) |
|
return response in ('Y', 'y') |
|
|
|
def require_write_permissions(path): |
|
parent = os.path.abspath(os.path.dirname(path)) |
|
logger.debug('Verifying write permission in directory parent="%s"' % (parent,)) |
|
if not os.access(parent, os.W_OK): |
|
raise Exception('Missing necessary write permission for parent directory "%s"; operation aborted' % (parent,)) |
|
|
|
def mv_preserving_parent_mtime(src, dest, opts): |
|
""" |
|
Moves the specified source path to a given destination, and restores |
|
the filesystem access and modified timestamp values for both src and dest |
|
after completing the mv operation. |
|
|
|
|
|
IMPORTANT |
|
--------- |
|
1. This is best effort only, not atomic or transactional! |
|
|
|
2. Take note of the permission tests before performing the rename. The |
|
checks verify write access to src and dest's parent directories. |
|
|
|
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 |
|
((src == os.sep or (platform.system() == 'Windows' and re.match(r'^[A-Z]:%s?' % (os.sep,), src))) or |
|
(dest == os.sep or (platform.system() == 'Windows' and re.match(r'^[A-Z]:%s?' % (os.sep,), dest))))): |
|
raise Exception('Cowardly refusing to operate on a root src or dest path') |
|
|
|
if len(set((src, dest)).intersection(set(('', '.', '..')))) > 0: |
|
raise Exception('Invalid src "%s" or dest "%s" path, must have a parent directory and value cannot be empty string, ".", or ".."' % (src, dest)) |
|
|
|
s_parent = os.path.dirname(src) |
|
d_parent = dest if os.path.isdir(dest) else os.path.dirname(dest) |
|
|
|
if src == dest: |
|
raise Exception('Invalid parameters, src and dest cannot be the same; but src="%s" dest="%s"' % (src, dest)) |
|
if src == s_parent: |
|
raise Exception('Invalid path, src parent directory="%s" should not equal src path="%s"' % (s_parent, src)) |
|
|
|
s_st = os.stat(s_parent) |
|
s_atime = s_st[stat.ST_ATIME] |
|
s_mtime = s_st[stat.ST_MTIME] |
|
|
|
d_st = os.stat(d_parent) |
|
d_atime = d_st[stat.ST_ATIME] |
|
d_mtime = d_st[stat.ST_MTIME] |
|
|
|
require_write_permissions(s_parent) |
|
require_write_permissions(d_parent) |
|
|
|
modified = False |
|
|
|
try: |
|
if os.path.isdir(src) or os.path.isfile(src): |
|
if _prompt_for_confirmation(src, dest, opts): |
|
modified = True |
|
if opts.dry_run: |
|
logger.info('Dry-run: Would have moved src=%s to dest=%s' % (src, dest,)) |
|
else: |
|
logger.debug('Moving "%s" to "%s"' % (src, dest)) |
|
shutil.move(src, dest) |
|
else: |
|
raise Exception('Src path "%s" is not a file or directory' % (src,)) |
|
finally: |
|
if modified: |
|
logger.debug('Restoring access and modification timestamps for src parent="%s" and dest parent="%s"' % (s_parent, d_parent)) |
|
if opts.dry_run: |
|
logger.info('Dry-run: Would have set src parent=%s atime=%s mtime=%s' % (s_parent, s_atime, s_mtime)) |
|
logger.info('Dry-run: Would have set dest parent=%s atime=%s mtime=%s' % (d_parent, d_atime, d_mtime)) |
|
else: |
|
os.utime(s_parent, (s_atime, d_mtime)) |
|
os.utime(d_parent, (d_atime, d_mtime)) |
|
|
|
def main(src, dest, opts): |
|
try: |
|
mv_preserving_parent_mtime(src, dest, opts) |
|
return 0 |
|
except BaseException: |
|
logger.exception('Caught exception in main while processing src="%s" dest="%s"' % (src, dest,)) |
|
if opts.force: |
|
return 0 |
|
return 1 |
|
|
|
if __name__ == '__main__': |
|
opts = parse_opts(sys.argv[1:]) |
|
sys.exit(main(opts.src, opts.dest, opts)) |
Hi there!
I like your scripts :-)
One thing that would be nice, would be if it worked on "non-direct" folders.. because if I try to "cd into/a/directory" and want to delete a folder in there called "deleteme"... and just do a rm_script.py -r deleteme it would fail with a "FileNotFoundError"... but if I run the script with the full path, it works as expected...
I'm not a Python guy, but I am sure there is a way to get the full path from a folder you are pointing to?
That would make your scripts perfect :-)