X-Git-Url: https://arthur.barton.de/cgi-bin/gitweb.cgi?a=blobdiff_plain;f=lib%2Fbup%2Fhelpers.py;h=81770339e4955543a498b134f7ad0b329a83c9b6;hb=HEAD;hp=28d1c6d3595b612f17cfb14decadb0279ee4b8b9;hpb=d75a9b7fafc1ff95b91b9ee5f38153e767144d1a;p=bup.git diff --git a/lib/bup/helpers.py b/lib/bup/helpers.py index 28d1c6d..8177033 100644 --- a/lib/bup/helpers.py +++ b/lib/bup/helpers.py @@ -2,16 +2,18 @@ from __future__ import absolute_import, division from collections import namedtuple -from contextlib import contextmanager +from contextlib import ExitStack from ctypes import sizeof, c_void_p from math import floor from os import environ from subprocess import PIPE, Popen +from tempfile import mkdtemp +from shutil import rmtree import sys, os, subprocess, errno, select, mmap, stat, re, struct -import hashlib, heapq, math, operator, time, tempfile +import hashlib, heapq, math, operator, time from bup import _helpers -from bup import compat +from bup import io from bup.compat import argv_bytes, byte_int, nullcontext, pending_raise from bup.io import byte_stream, path_msg # This function should really be in helpers, not in bup.options. But we @@ -31,16 +33,24 @@ def nullcontext_if_not(manager): return manager if manager is not None else nullcontext() -@contextmanager -def finalized(enter_result=None, finalize=None): - assert finalize - try: - yield enter_result - except BaseException as ex: - with pending_raise(ex): - finalize(enter_result) - finalize(enter_result) - +class finalized: + def __init__(self, enter_result=None, finalize=None): + assert finalize + self.finalize = finalize + self.enter_result = enter_result + def __enter__(self): + return self.enter_result + def __exit__(self, exc_type, exc_value, traceback): + self.finalize(self.enter_result) + +def temp_dir(*args, **kwargs): + # This is preferable to tempfile.TemporaryDirectory because the + # latter uses @contextmanager, and so will always eventually be + # deleted if it's handed to an ExitStack, whenever the stack is + # gc'ed, even if you pop_all() (the new stack will also trigger + # the deletion) because + # https://github.com/python/cpython/issues/88458 + return finalized(mkdtemp(*args, **kwargs), lambda x: rmtree(x)) sc_page_size = os.sysconf('SC_PAGE_SIZE') assert(sc_page_size > 0) @@ -285,7 +295,7 @@ def squote(x): def quote(x): if isinstance(x, bytes): return bquote(x) - if isinstance(x, compat.str_type): + if isinstance(x, str): return squote(x) assert False # some versions of pylint get confused @@ -300,11 +310,11 @@ def shstr(cmd): call() and friends. e.g. log(shstr(cmd)); call(cmd) """ - if isinstance(cmd, (bytes, compat.str_type)): + if isinstance(cmd, (bytes, str)): return cmd elif all(isinstance(x, bytes) for x in cmd): return b' '.join(map(bquote, cmd)) - elif all(isinstance(x, compat.str_type) for x in cmd): + elif all(isinstance(x, str) for x in cmd): return ' '.join(map(squote, cmd)) raise TypeError('unsupported shstr argument: ' + repr(cmd)) @@ -343,7 +353,7 @@ def _argmax_base(command): base_size = 2048 for c in command: base_size += len(command) + 1 - for k, v in compat.items(environ): + for k, v in environ.items(): base_size += len(k) + len(v) + 2 + sizeof(c_void_p) return base_size @@ -716,40 +726,52 @@ def chunkyreader(f, count = None): yield b -@contextmanager -def atomically_replaced_file(name, mode='w', buffering=-1): - """Yield a file that will be atomically renamed name when leaving the block. - - This contextmanager yields an open file object that is backed by a - temporary file which will be renamed (atomically) to the target - name if everything succeeds. - - The mode and buffering arguments are handled exactly as with open, - and the yielded file will have very restrictive permissions, as - per mkstemp. - - E.g.:: - - with atomically_replaced_file('foo.txt', 'w') as f: - f.write('hello jack.') - - """ - - (ffd, tempname) = tempfile.mkstemp(dir=os.path.dirname(name), - text=('b' not in mode)) - try: - try: - f = os.fdopen(ffd, mode, buffering) - except: - os.close(ffd) - raise - try: - yield f - finally: - f.close() - os.rename(tempname, name) - finally: - unlink(tempname) # nonexistant file is ignored +class atomically_replaced_file: + def __init__(self, path, mode='w', buffering=-1): + """Return a context manager supporting the atomic replacement of a file. + + The context manager yields an open file object that has been + created in a mkdtemp-style temporary directory in the same + directory as the path. The temporary file will be renamed to + the target path (atomically if the platform allows it) if + there are no exceptions, and the temporary directory will + always be removed. Calling cancel() will prevent the + replacement. + + The file object will have a name attribute containing the + file's path, and the mode and buffering arguments will be + handled exactly as with open(). The resulting permissions + will also match those produced by open(). + + E.g.:: + + with atomically_replaced_file('foo.txt', 'w') as f: + f.write('hello jack.') + + """ + assert 'w' in mode + self.path = path + self.mode = mode + self.buffering = buffering + self.canceled = False + self.tmp_path = None + self.cleanup = ExitStack() + def __enter__(self): + with self.cleanup: + parent, name = os.path.split(self.path) + tmpdir = self.cleanup.enter_context(temp_dir(dir=parent, + prefix=name + b'-')) + self.tmp_path = tmpdir + b'/pending' + f = open(self.tmp_path, mode=self.mode, buffering=self.buffering) + f = self.cleanup.enter_context(f) + self.cleanup = self.cleanup.pop_all() + return f + def __exit__(self, exc_type, exc_value, traceback): + with self.cleanup: + if not (self.canceled or exc_type): + os.rename(self.tmp_path, self.path) + def cancel(self): + self.canceled = True def slashappend(s): @@ -770,7 +792,7 @@ def _mmap_do(f, sz, flags, prot, close): # string has all the same behaviour of a zero-length map, ie. it has # no elements :) return '' - map = compat.mmap(f.fileno(), sz, flags, prot) + map = io.mmap(f.fileno(), sz, flags, prot) if close: f.close() # map will persist beyond file close return map @@ -828,22 +850,23 @@ if _mincore: page_count = (st.st_size + sc_page_size - 1) // sc_page_size; chunk_count = (st.st_size + _fmincore_chunk_size - 1) // _fmincore_chunk_size result = bytearray(page_count) - for ci in compat.range(chunk_count): + for ci in range(chunk_count): pos = _fmincore_chunk_size * ci; msize = min(_fmincore_chunk_size, st.st_size - pos) try: - m = compat.mmap(fd, msize, mmap.MAP_PRIVATE, 0, 0, pos) + m = io.mmap(fd, msize, mmap.MAP_PRIVATE, 0, 0, pos) except mmap.error as ex: - if ex.errno == errno.EINVAL or ex.errno == errno.ENODEV: + if ex.errno in (errno.EINVAL, errno.ENODEV): # Perhaps the file was a pipe, i.e. "... | bup split ..." return None raise ex - try: - _mincore(m, msize, 0, result, ci * pages_per_chunk) - except OSError as ex: - if ex.errno == errno.ENOSYS: - return None - raise + with m: + try: + _mincore(m, msize, 0, result, ci * pages_per_chunk) + except OSError as ex: + if ex.errno == errno.ENOSYS: + return None + raise return result @@ -961,7 +984,7 @@ def columnate(l, prefix): while len(l) % ncols: l.append(nothing) rows = len(l) // ncols - for s in compat.range(0, len(l), rows): + for s in range(0, len(l), rows): cols.append(l[s:s+rows]) out = nothing fmt = b'%-*s' if binary else '%-*s'