Skip to content

Instantly share code, notes, and snippets.

@jaytaylor
Last active December 27, 2022 03:11
Show Gist options
  • Save jaytaylor/e2e0b53baf224f4e973b252370499de7 to your computer and use it in GitHub Desktop.
Save jaytaylor/e2e0b53baf224f4e973b252370499de7 to your computer and use it in GitHub Desktop.
Python scripts to Remove, modify, or create a file without changing the modification time of the parent directory.

*_preserving_parent_mtime.py

Python programs to remove or modify files without changing the modification time of the parent directory.

Quick Install

curl -fSLO https://gist.github.com/jaytaylor/e2e0b53baf224f4e973b252370499de7/raw/bc175ba7008626ebc3f356c16f8240ebe578c5a0/rm_preserving_parent_mtime.py
curl -fSLO https://gist.github.com/jaytaylor/e2e0b53baf224f4e973b252370499de7/raw/bc175ba7008626ebc3f356c16f8240ebe578c5a0/mv_preserving_parent_mtime.py
chmod a+x rm_preserving_parent_mtime.py mv_preserving_parent_mtime.py

See also the original: https://gist.github.com/jaytaylor/ea1b6082ea5bcea7a6b65518d91238f5 and https://unix.stackexchange.com/a/565595/22709

#!/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))
#!/usr/bin/env python3
# -*- 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
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('-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.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 rm_preserving_parent_mtime(path, opts):
"""
Deletes the specified path, and restores the filesystem access and modified
timestamp values after permanently removing path.
IMPORTANT
---------
1. This is best effort only, not atomic or transactional!
2. 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 and value cannot be empty string, ".", or ".."' % (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
if opts.dry_run:
logger.info('Dry-run: Would have removed path=%s' % (path,))
else:
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
if opts.dry_run:
logger.info('Dry-run: Would have removed path=%s' % (path,))
else:
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,))
if opts.dry_run:
logger.info('Dry-run: Would have set parent=%s atime=%s mtime=%s' % (parent, atime, mtime))
else:
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 while processing path=%s' % (path,))
if opts.force:
return 0
return 1
if __name__ == '__main__':
opts = parse_opts(sys.argv[1:])
sys.exit(main(opts.paths, opts))
@heinowalther
Copy link

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

@heinowalther
Copy link

Apparently it wan't that hard to fix in my hacky way :-)

I changed the "def main" to look like this:

def main(paths, opts):
try:
for path in paths:
path = os.path.abspath(path)
rm_preserving_parent_mtime(path, opts)
return 0

the os.path.abspath just returns the unique path to the file or folder you are pointing it to...
Now I have only tested this modification no my linux box and it seems to work for me...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment