Escrituras seguras y tolerantes a fallos

Tengo un proceso de larga duración que escribe muchas cosas en un archivo. El resultado debe ser todo o nada, por lo que escribo en un archivo temporal y le cambio el nombre real al final. Actualmente, mi código es así:

filename = 'whatever' tmpname = 'whatever' + str(time.time()) with open(tmpname, 'wb') as fp: fp.write(stuff) fp.write(more stuff) if os.path.exists(filename): os.unlink(filename) os.rename(tmpname, filename) 

No estoy contento con eso por varias razones:

  • no se limpia correctamente si ocurre una excepción
  • ignora los problemas de concurrencia
  • no es reutilizable (necesito esto en diferentes lugares en mi progtwig)

¿Alguna sugerencia de cómo mejorar mi código? ¿Hay alguna biblioteca que me pueda ayudar?

Puedes usar el módulo tempfile de Python para darte un nombre de archivo temporal. Puede crear un archivo temporal de una manera segura para subprocesos en lugar de crear uno usando time.time() que puede devolver el mismo nombre si se usa en varios subprocesos al mismo tiempo.

Como se sugiere en un comentario a su pregunta, esto se puede combinar con el uso de un administrador de contexto. Puede obtener algunas ideas sobre cómo implementar lo que quiere hacer al ver las fonts tempfile.py Python.

El siguiente fragmento de código puede hacer lo que quieras. Utiliza algunos de los elementos internos de los objetos devueltos desde tempfile .

  • La creación de archivos temporales es segura para subprocesos.
  • El cambio de nombre de los archivos al completarse con éxito es atómico, al menos en Linux. No hay una comprobación separada entre os.path.exists() y os.rename() que podría introducir una condición de carrera. Para un cambio de nombre atómico en Linux, la fuente y los destinos deben estar en el mismo sistema de archivos, por lo que este código coloca el archivo temporal en el mismo directorio que el archivo de destino.
  • La clase RenamedTemporaryFile debe comportarse como un NamedTemporaryFile para la mayoría de los propósitos, excepto cuando se cierra con el administrador de contexto, se cambia el nombre del archivo.

Muestra:

 import tempfile import os class RenamedTemporaryFile(object): """ A temporary file object which will be renamed to the specified path on exit. """ def __init__(self, final_path, **kwargs): tmpfile_dir = kwargs.pop('dir', None) # Put temporary file in the same directory as the location for the # final file so that an atomic move into place can occur. if tmpfile_dir is None: tmpfile_dir = os.path.dirname(final_path) self.tmpfile = tempfile.NamedTemporaryFile(dir=tmpfile_dir, **kwargs) self.final_path = final_path def __getattr__(self, attr): """ Delegate attribute access to the underlying temporary file object. """ return getattr(self.tmpfile, attr) def __enter__(self): self.tmpfile.__enter__() return self def __exit__(self, exc_type, exc_val, exc_tb): if exc_type is None: self.tmpfile.delete = False result = self.tmpfile.__exit__(exc_type, exc_val, exc_tb) os.rename(self.tmpfile.name, self.final_path) else: result = self.tmpfile.__exit__(exc_type, exc_val, exc_tb) return result 

Puedes usarlo así:

 with RenamedTemporaryFile('whatever') as f: f.write('stuff') 

Durante la escritura, el contenido va a un archivo temporal, al salir se cambia el nombre del archivo. Este código probablemente necesitará algunos ajustes, pero la idea general debería ayudarlo a comenzar.

Para escribir todo o nada en un archivo de forma fiable:

 import os from contextlib import contextmanager from tempfile import NamedTemporaryFile if not hasattr(os, 'replace'): os.replace = os.rename #NOTE: it won't work for existing files on Windows @contextmanager def FaultTolerantFile(name): dirpath, filename = os.path.split(name) # use the same dir for os.rename() to work with NamedTemporaryFile(dir=dirpath, prefix=filename, suffix='.tmp') as f: yield f f.flush() # libc -> OS os.fsync(f) # OS -> disc (note: on OSX it is not enough) f.delete = False # don't delete tmp file if `replace()` fails f.close() os.replace(f.name, name) 

Consulte también ¿Es seguro rename () sin fsync ()? (mencionado por @Mihai Stan )

Uso

 with FaultTolerantFile('very_important_file') as file: file.write('either all ') file.write('or nothing is written') 

Para implementar os.replace() faltantes, puede llamar a MoveFileExW(src, dst, MOVEFILE_REPLACE_EXISTING) (a través de los módulos win32file o ctypes) en Windows.

En el caso de varios subprocesos, puede llamar a queue.put(data) desde diferentes subprocesos y escribir en un archivo en un subproceso dedicado:

  for data in iter(queue.get, None): file.write(data) 

queue.put(None) rompe el bucle.

Como alternativa, podría usar lockings (subprocesos, multiprocesamiento, locking de archivos) para sincronizar el acceso:

 def write(self, data): with self.lock: self.file.write(data) 

Puede usar el módulo de locking de archivos para bloquear el archivo mientras escribe en él. Cualquier bash posterior de locking se bloqueará hasta que se libere el locking del proceso / subproceso anterior.

 from lockfile import FileLock with FileLock(filename): #open your file here.... 

De esta manera, evita los problemas de concurrencia y no tiene que limpiar ningún archivo sobrante si se produce una excepción.

El constructo with es útil para limpiar en la salida, pero no para el sistema de confirmación / retrotracción que desea. Se puede usar un bloque try / except / else para eso.

También debe utilizar una forma estándar para crear el nombre de archivo temporal, por ejemplo, con el módulo tempfile .

Y recuerda fsync antes de cambiar el nombre

A continuación se muestra el código completo modificado:

 import time, os, tempfile def begin_file(filepath): (filedir, filename) = os.path.split(filepath) tmpfilepath = tempfile.mktemp(prefix=filename+'_', dir=filedir) return open(os.path.join(filedir, tmpfilepath), 'wb') def commit_file(f): tmppath = f.name (filedir, tmpname) = os.path.split(tmppath) origpath = os.path.join(filedir,tmpname.split('_')[0]) os.fsync(f.fileno()) f.close() if os.path.exists(origpath): os.unlink(origpath) os.rename(tmppath, origpath) def rollback_file(f): tmppath = f.name f.close() os.unlink(tmppath) fp = begin_file('whatever') try: fp.write('stuff') except: rollback_file(fp) raise else: commit_file(fp)