Repository: spyder-ide/spyder

Issue:
KeyError when autosaving file
## Description

### What steps will reproduce the problem?

<!--- You can use Markdown here --->

It's happened whole I guess it tried to save changes to the file.

### Traceback
```python-traceback
  File "/home/ravi/Anaconda/lib/python3.7/site-packages/spyder/plugins/editor/utils/autosave.py", line 122, in do_autosave
    stack.autosave.autosave_all()
  File "/home/ravi/Anaconda/lib/python3.7/site-packages/spyder/plugins/editor/utils/autosave.py", line 305, in autosave_all
    self.maybe_autosave(index)
  File "/home/ravi/Anaconda/lib/python3.7/site-packages/spyder/plugins/editor/utils/autosave.py", line 265, in maybe_autosave
    orig_hash = self.file_hashes[orig_filename]
KeyError: '/home/ravi/Documents/fj/tictac.py'
WARNING:spyder.widgets.github.backend:Failed to send bug report on Github. response={'code': 401, 'json': {'message': 'Bad credentials', 'documentation_url': 'https://developer.github.com/v3'}}
WARNING:spyder.widgets.github.backend:Failed to send bug report on Github. response={'code': 401, 'json': {'message': 'Bad credentials', 'documentation_url': 'https://developer.github.com/v3'}}
```

## Versions

* Spyder version: 4.0.0b4 
* Python version: 3.7.3
* Qt version: 5.12.4
* PyQt5 version: 5.12.3
* Operating System: Linux 5.2.4-1-MANJARO

### Dependencies

```
pygments >=2.0    :  2.4.2 (OK)
qdarkstyle >=2.6.4:  2.7 (OK)
sphinx >=0.6.6    :  2.1.2 (OK)
psutil >=0.3      :  5.6.3 (OK)
pyls >=0.27.0     :  0.28.0 (OK)
nbconvert >=4.0   :  5.5.0 (OK)
pandas >=0.13.1   :  0.24.2 (OK)
numpy >=1.7       :  1.16.4 (OK)
sympy >=0.7.3     :  1.4 (OK)
cython >=0.21     :  0.29.12 (OK)
qtconsole >=4.5.2 :  4.5.2 (OK)
IPython >=4.0     :  7.6.1 (OK)
matplotlib >=2.0.0:  3.1.0 (OK)
pylint >=0.25     :  2.3.1 (OK)
```



Discussion:
@DeadVampire, did you remove `tictac.py` outside Spyder?
We should probably add a check for existence before running autosave @jitseniesen ?

I don't see how this could have happened. It looks like OP somehow opened the file in a non-standard way. But, yes, we )read I) need to add some check.
I found one possible cause: if you rename a file in the file explorer, then it is automatically renamed in the editor, but the autosave information is not updated. This leads to the KeyError exception posted.

@DeadVampire Do you remember by any chance whether you renamed the file?
Yes, but only in the internal file explorer. 

Resolving patch:
diff --git a/spyder/plugins/editor/plugin.py b/spyder/plugins/editor/plugin.py
--- a/spyder/plugins/editor/plugin.py
+++ b/spyder/plugins/editor/plugin.py
@@ -2048,13 +2048,20 @@ def removed_tree(self, dirname):
                 self.close_file_from_name(fname)
 
     def renamed(self, source, dest):
-        """File was renamed in file explorer widget or in project explorer"""
+        """
+        Propagate file rename to editor stacks and autosave component.
+
+        This function is called when a file is renamed in the file explorer
+        widget or the project explorer.
+        """
         filename = osp.abspath(to_text_string(source))
         index = self.editorstacks[0].has_filename(filename)
         if index is not None:
             for editorstack in self.editorstacks:
                 editorstack.rename_in_data(filename,
                                            new_filename=to_text_string(dest))
+        self.editorstacks[0].autosave.file_renamed(
+            filename, to_text_string(dest))
 
     def renamed_tree(self, source, dest):
         """Directory was renamed in file explorer or in project explorer."""
diff --git a/spyder/plugins/editor/utils/autosave.py b/spyder/plugins/editor/utils/autosave.py
--- a/spyder/plugins/editor/utils/autosave.py
+++ b/spyder/plugins/editor/utils/autosave.py
@@ -266,6 +266,8 @@ 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
@@ -370,3 +372,18 @@ 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
+        """
+        old_hash = self.file_hashes[old_name]
+        self.remove_autosave_file(old_name)
+        del self.file_hashes[old_name]
+        self.file_hashes[new_name] = old_hash
+        index = self.stack.has_filename(new_name)
+        self.maybe_autosave(index)


Test patch:
diff --git a/spyder/plugins/editor/tests/test_plugin.py b/spyder/plugins/editor/tests/test_plugin.py
--- a/spyder/plugins/editor/tests/test_plugin.py
+++ b/spyder/plugins/editor/tests/test_plugin.py
@@ -172,6 +172,13 @@ def test_closing_editor_plugin_stops_autosave_timer(editor_plugin):
     assert not editor.autosave.timer.isActive()
 
 
+def test_renamed_propagates_to_autosave(editor_plugin, mocker):
+    editorstack = editor_plugin.get_current_editorstack()
+    mocker.patch.object(editorstack.autosave, 'file_renamed')
+    editor_plugin.renamed('src', 'dest')
+    editorstack.autosave.file_renamed.assert_called()
+
+
 def test_go_to_prev_next_cursor_position(editor_plugin, python_files):
     """
     Test the previous and next cursor position feature of the Editor.
diff --git a/spyder/plugins/editor/utils/tests/test_autosave.py b/spyder/plugins/editor/utils/tests/test_autosave.py
--- a/spyder/plugins/editor/utils/tests/test_autosave.py
+++ b/spyder/plugins/editor/utils/tests/test_autosave.py
@@ -208,11 +208,41 @@ def test_autosave_remove_autosave_file(mocker, exception):
     addon.file_hashes = {'autosave': 42}
 
     addon.remove_autosave_file(fileinfo.filename)
+
     assert addon.name_mapping == {}
     assert addon.file_hashes == {}
     mock_remove.assert_any_call('autosave')
     assert mock_dialog.called == exception
 
 
+def test_autosave_file_renamed(mocker, tmpdir):
+    """Test that AutosaveForStack.file_renamed removes the old autosave file,
+    creates a new one, and updates `name_mapping` and `file_hashes`."""
+    mock_remove = mocker.patch('os.remove')
+    mocker.patch('spyder.plugins.editor.utils.autosave.get_conf_path',
+                 return_value=str(tmpdir))
+    mock_editor = mocker.Mock()
+    mock_fileinfo = mocker.Mock(editor=mock_editor, filename='new_foo.py',
+                                newly_created=False)
+    mock_document = mocker.Mock()
+    mock_fileinfo.editor.document.return_value = mock_document
+    mock_stack = mocker.Mock(data=[mock_fileinfo])
+    mock_stack.has_filename.return_value = 0
+    mock_stack.compute_hash.return_value = 3
+    addon = AutosaveForStack(mock_stack)
+    old_autosavefile = str(tmpdir.join('old_foo.py'))
+    new_autosavefile = str(tmpdir.join('new_foo.py'))
+    addon.name_mapping = {'old_foo.py': old_autosavefile}
+    addon.file_hashes = {'old_foo.py': 1, old_autosavefile: 42}
+
+    addon.file_renamed('old_foo.py', 'new_foo.py')
+
+    mock_remove.assert_any_call(old_autosavefile)
+    mock_stack._write_to_file.assert_called_with(
+        mock_fileinfo, new_autosavefile)
+    assert addon.name_mapping == {'new_foo.py': new_autosavefile}
+    assert addon.file_hashes == {'new_foo.py': 1, new_autosavefile: 3}
+
+
 if __name__ == "__main__":
     pytest.main()