# -*- coding: utf-8 -*- # # Copyright © Spyder Project Contributors # Licensed under the terms of the MIT License # (see spyder/__init__.py for details) """ Autosave components for the Editor plugin and the EditorStack widget The autosave system regularly checks the contents of all opened files and saves a copy in the autosave directory if the contents are different from the autosave file (if it exists) or original file (if there is no autosave file). The mapping between original files and autosave files is stored in the variable `name_mapping` and saved in the file `pidNNN.txt` in the autosave directory, where `NNN` stands for the pid. This filename is chosen so that multiple instances of Spyder can run simultaneously. File contents are compared using their hash. The variable `file_hashes` contains the hash of all files currently open in the editor and all autosave files. On startup, the contents of the autosave directory is checked and if autosave files are found, the user is asked whether to recover them; see `spyder/plugins/editor/widgets/recover.py`. """ # Standard library imports import ast import logging import os import os.path as osp import re # Third party imports from qtpy.QtCore import QTimer # Local imports from spyder.config.base import _, get_conf_path, running_under_pytest from spyder.plugins.editor.widgets.autosaveerror import AutosaveErrorDialog from spyder.plugins.editor.widgets.recover import RecoveryDialog from spyder.py3compat import PY2 from spyder.utils.programs import is_spyder_process logger = logging.getLogger(__name__) class AutosaveForPlugin(object): """ Component of editor plugin implementing autosave functionality. Attributes: name_mapping (dict): map between names of opened and autosave files. file_hashes (dict): map between file names and hash of their contents. This is used for both files opened in the editor and their corresponding autosave files. """ # Interval (in ms) between two autosaves DEFAULT_AUTOSAVE_INTERVAL = 60 * 1000 def __init__(self, editor): """ Constructor. Autosave is disabled after construction and needs to be enabled explicitly if required. Args: editor (Editor): editor plugin. """ self.editor = editor self.name_mapping = {} self.file_hashes = {} self.timer = QTimer(self.editor) self.timer.setSingleShot(True) self.timer.timeout.connect(self.do_autosave) self._enabled = False # Can't use setter here self._interval = self.DEFAULT_AUTOSAVE_INTERVAL @property def enabled(self): """ Get or set whether autosave component is enabled. The setter will start or stop the autosave component if appropriate. """ return self._enabled @enabled.setter def enabled(self, new_enabled): if new_enabled == self.enabled: return self.stop_autosave_timer() self._enabled = new_enabled self.start_autosave_timer() @property def interval(self): """ Interval between two autosaves, in milliseconds. The setter will perform an autosave if the interval is changed and autosave is enabled. """ return self._interval @interval.setter def interval(self, new_interval): if new_interval == self.interval: return self.stop_autosave_timer() self._interval = new_interval if self.enabled: self.do_autosave() def start_autosave_timer(self): """ Start a timer which calls do_autosave() after `self.interval`. The autosave timer is only started if autosave is enabled. """ if self.enabled: self.timer.start(self.interval) def stop_autosave_timer(self): """Stop the autosave timer.""" self.timer.stop() def do_autosave(self): """Instruct current editorstack to autosave files where necessary.""" logger.debug('Autosave triggered') stack = self.editor.get_current_editorstack() stack.autosave.autosave_all() self.start_autosave_timer() def get_files_to_recover(self): """ Get list of files to recover from pid files in autosave dir. This returns a tuple `(files_to_recover, pid_files)`. In this tuple, `files_to_recover` is a list of tuples containing the original file names and the corresponding autosave file names, as recorded in the pid files in the autosave directory. Any files in the autosave directory which are not listed in a pid file, are also included, with the original file name set to `None`. The second entry, `pid_files`, is a list with the names of the pid files. """ autosave_dir = get_conf_path('autosave') if not os.access(autosave_dir, os.R_OK): return [], [] files_to_recover = [] files_mentioned = [] pid_files = [] non_pid_files = [] # In Python 3, easier to use os.scandir() for name in os.listdir(autosave_dir): full_name = osp.join(autosave_dir, name) match = re.match(r'pid([0-9]*)\.txt\Z', name) if match: pid_files.append(full_name) logger.debug('Reading pid file: {}'.format(full_name)) with open(full_name) as pidfile: txt = pidfile.read() try: txt_as_dict = ast.literal_eval(txt) except (SyntaxError, ValueError): # Pid file got corrupted, see spyder-ide/spyder#11375 logger.error('Error parsing pid file {}' .format(full_name)) logger.error('Contents: {}'.format(repr(txt))) txt_as_dict = {} files_mentioned += [autosave for (orig, autosave) in txt_as_dict.items()] pid = int(match.group(1)) if is_spyder_process(pid): logger.debug('Ignoring files in {}'.format(full_name)) else: files_to_recover += list(txt_as_dict.items()) else: non_pid_files.append(full_name) # Add all files not mentioned in any pid file. This can only happen if # the pid file somehow got corrupted. for filename in set(non_pid_files) - set(files_mentioned): files_to_recover.append((None, filename)) logger.debug('Added unmentioned file: {}'.format(filename)) return files_to_recover, pid_files def try_recover_from_autosave(self): """ Offer to recover files from autosave. Read pid files to get a list of files that can possibly be recovered, then ask the user what to do with these files, and finally remove the pid files. """ files_to_recover, pidfiles = self.get_files_to_recover() parent = self.editor if running_under_pytest() else self.editor.main dialog = RecoveryDialog(files_to_recover, parent=parent) dialog.exec_if_nonempty() self.recover_files_to_open = dialog.files_to_open[:] for pidfile in pidfiles: try: os.remove(pidfile) except (IOError, OSError): pass def register_autosave_for_stack(self, autosave_for_stack): """ Register an AutosaveForStack object. This replaces the `name_mapping` and `file_hashes` attributes in `autosave_for_stack` with references to the corresponding attributes of `self`, so that all AutosaveForStack objects share the same data. """ autosave_for_stack.name_mapping = self.name_mapping autosave_for_stack.file_hashes = self.file_hashes class AutosaveForStack(object): """ Component of EditorStack implementing autosave functionality. In Spyder, the `name_mapping` and `file_hashes` are set to references to the corresponding variables in `AutosaveForPlugin`. Attributes: stack (EditorStack): editor stack this component belongs to. name_mapping (dict): map between names of opened and autosave files. file_hashes (dict): map between file names and hash of their contents. This is used for both files opened in the editor and their corresponding autosave files. """ def __init__(self, editorstack): """ Constructor. Args: editorstack (EditorStack): editor stack this component belongs to. """ self.stack = editorstack self.name_mapping = {} self.file_hashes = {} def create_unique_autosave_filename(self, filename, autosave_dir): """ Create unique autosave file name for specified file name. The created autosave file name does not yet exist either in `self.name_mapping` or on disk. Args: filename (str): original file name autosave_dir (str): directory in which autosave files are stored """ basename = osp.basename(filename) autosave_filename = osp.join(autosave_dir, basename) if (autosave_filename in self.name_mapping.values() or osp.exists(autosave_filename)): counter = 0 root, ext = osp.splitext(basename) while (autosave_filename in self.name_mapping.values() or osp.exists(autosave_filename)): counter += 1 autosave_basename = '{}-{}{}'.format(root, counter, ext) autosave_filename = osp.join(autosave_dir, autosave_basename) return autosave_filename def save_autosave_mapping(self): """ Writes current autosave mapping to a pidNNN.txt file. This function should be called after updating `self.autosave_mapping`. The NNN in the file name is the pid of the Spyder process. If the current autosave mapping is empty, then delete the file if it exists. """ autosave_dir = get_conf_path('autosave') my_pid = os.getpid() pidfile_name = osp.join(autosave_dir, 'pid{}.txt'.format(my_pid)) if self.name_mapping: with open(pidfile_name, 'w') as pidfile: if PY2: pidfile.write(repr(self.name_mapping)) else: pidfile.write(ascii(self.name_mapping)) else: try: os.remove(pidfile_name) except (IOError, OSError): pass def remove_autosave_file(self, filename): """ Remove autosave file for specified file. This function also updates `self.name_mapping` and `self.file_hashes`. If there is no autosave file, then the function returns without doing anything. """ if filename not in self.name_mapping: return autosave_filename = self.name_mapping[filename] try: os.remove(autosave_filename) except EnvironmentError as error: action = (_('Error while removing autosave file {}') .format(autosave_filename)) msgbox = AutosaveErrorDialog(action, error) msgbox.exec_if_enabled() del self.name_mapping[filename] del self.file_hashes[autosave_filename] self.save_autosave_mapping() logger.debug('Removing autosave file %s', autosave_filename) def get_autosave_filename(self, filename): """ Get name of autosave file for specified file name. This function uses the dict in `self.name_mapping`. If `filename` is in the mapping, then return the corresponding autosave file name. Otherwise, construct a unique file name and update the mapping. Args: filename (str): original file name """ try: autosave_filename = self.name_mapping[filename] except KeyError: autosave_dir = get_conf_path('autosave') if not osp.isdir(autosave_dir): try: os.mkdir(autosave_dir) except EnvironmentError as error: action = _('Error while creating autosave directory') msgbox = AutosaveErrorDialog(action, error) msgbox.exec_if_enabled() autosave_filename = self.create_unique_autosave_filename( filename, autosave_dir) self.name_mapping[filename] = autosave_filename self.save_autosave_mapping() logger.debug('New autosave file name') return autosave_filename def maybe_autosave(self, index): """ Autosave a file if necessary. If the file is newly created (and thus not named by the user), do nothing. If the current contents are the same as the autosave file (if it exists) or the original file (if no autosave filee exists), then do nothing. If the current contents are the same as the file on disc, but the autosave file is different, then remove the autosave file. In all other cases, autosave the file. Args: index (int): index into self.stack.data """ finfo = self.stack.data[index] if finfo.newly_created: return orig_filename = finfo.filename try: orig_hash = self.file_hashes[orig_filename] except KeyError: # This should not happen, but it does: spyder-ide/spyder#11468 # In this case, use an impossible value for the hash, so that # contents of buffer are considered different from contents of # original file. logger.error('KeyError when retrieving hash of %s', orig_filename) orig_hash = None new_hash = self.stack.compute_hash(finfo) if orig_filename in self.name_mapping: autosave_filename = self.name_mapping[orig_filename] autosave_hash = self.file_hashes[autosave_filename] if new_hash != autosave_hash: if new_hash == orig_hash: self.remove_autosave_file(orig_filename) else: self.autosave(finfo) else: if new_hash != orig_hash: self.autosave(finfo) def autosave(self, finfo): """ Autosave a file. Save a copy in a file with name `self.get_autosave_filename()` and update the cached hash of the autosave file. An error dialog notifies the user of any errors raised when saving. Args: fileinfo (FileInfo): file that is to be autosaved. """ autosave_filename = self.get_autosave_filename(finfo.filename) logger.debug('Autosaving %s to %s', finfo.filename, autosave_filename) try: self.stack._write_to_file(finfo, autosave_filename) autosave_hash = self.stack.compute_hash(finfo) self.file_hashes[autosave_filename] = autosave_hash except EnvironmentError as error: action = (_('Error while autosaving {} to {}') .format(finfo.filename, autosave_filename)) msgbox = AutosaveErrorDialog(action, error) msgbox.exec_if_enabled() def autosave_all(self): """Autosave all opened files where necessary.""" for index in range(self.stack.get_stack_count()): self.maybe_autosave(index) def file_renamed(self, old_name, new_name): """ Update autosave files after a file is renamed. Args: old_name (str): name of file before it is renamed new_name (str): name of file after it is renamed """ try: old_hash = self.file_hashes[old_name] except KeyError: # This should not happen, but it does: spyder-ide/spyder#12396 logger.error('KeyError when handling rename %s -> %s', old_name, new_name) old_hash = None self.remove_autosave_file(old_name) if old_hash is not None: del self.file_hashes[old_name] self.file_hashes[new_name] = old_hash index = self.stack.has_filename(new_name) self.maybe_autosave(index)