Repository: spyder-ide/spyder

Issue:
Autoformat file on save prevents saving files
## Problem Description

Closing a modified file with `Autoformat file on save` active doesn't save the file

### What steps reproduce the problem?

0. Activate `Autoformat file on save`
1. New untitled file
2. Modify file
3. Close file answering yes to save it

### What is the expected output? What do you see instead?

Saved file vs File not saved




Discussion:
Note: This is probably caused due to closing the file while the the autoformat is being done since the `save` method returns immediately
Then you could use a QEventLoop to block the save method until the formatting response returns from the PyLSP.

Resolving patch:
diff --git a/spyder/plugins/editor/widgets/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor.py
--- a/spyder/plugins/editor/widgets/codeeditor.py
+++ b/spyder/plugins/editor/widgets/codeeditor.py
@@ -33,8 +33,8 @@
 from IPython.core.inputtransformer2 import TransformerManager
 from qtpy import QT_VERSION
 from qtpy.compat import to_qvariant
-from qtpy.QtCore import (QEvent, QRegExp, Qt, QTimer, QThread, QUrl, Signal,
-                         Slot)
+from qtpy.QtCore import (QEvent, QEventLoop, QRegExp, Qt, QTimer, QThread,
+                         QUrl, Signal, Slot)
 from qtpy.QtGui import (QColor, QCursor, QFont, QKeySequence, QPaintEvent,
                         QPainter, QMouseEvent, QTextCursor, QDesktopServices,
                         QKeyEvent, QTextDocument, QTextFormat, QTextOption,
@@ -520,6 +520,8 @@ def __init__(self, parent=None):
 
         # Autoformat on save
         self.format_on_save = False
+        self.format_eventloop = QEventLoop(None)
+        self.format_timer = QTimer(self)
 
         # Mouse tracking
         self.setMouseTracking(True)
@@ -2989,7 +2991,6 @@ def paste(self):
             if indentations:
                 max_dedent = min(indentations)
                 lines_adjustment = max(lines_adjustment, -max_dedent)
-    
             # Get new text
             remaining_lines = [
                 self.adjust_indentation(line, lines_adjustment)
diff --git a/spyder/plugins/editor/widgets/editor.py b/spyder/plugins/editor/widgets/editor.py
--- a/spyder/plugins/editor/widgets/editor.py
+++ b/spyder/plugins/editor/widgets/editor.py
@@ -16,7 +16,6 @@
 import os
 import os.path as osp
 import sys
-import functools
 import unicodedata
 
 # Third party imports
@@ -1901,10 +1900,26 @@ def save(self, index=None, force=False, save_new_files=True):
 
         try:
             if self.format_on_save and finfo.editor.formatting_enabled:
-                # Autoformat document and then save
+                # Wait for document autoformat and then save
+
+                # Waiting for the autoformat to complete is needed
+                # when the file is going to be closed after saving.
+                # See spyder-ide/spyder#17836
+                format_eventloop = finfo.editor.format_eventloop
+                format_timer = finfo.editor.format_timer
+                format_timer.setSingleShot(True)
+                format_timer.timeout.connect(format_eventloop.quit)
+
                 finfo.editor.sig_stop_operation_in_progress.connect(
                     lambda: self._save_file(finfo))
+                finfo.editor.sig_stop_operation_in_progress.connect(
+                    format_timer.stop)
+                finfo.editor.sig_stop_operation_in_progress.connect(
+                    format_eventloop.quit)
+
+                format_timer.start(10000)
                 finfo.editor.format_document()
+                format_eventloop.exec_()
             else:
                 self._save_file(finfo)
             return True


Test patch:
diff --git a/spyder/plugins/editor/widgets/tests/conftest.py b/spyder/plugins/editor/widgets/tests/conftest.py
--- a/spyder/plugins/editor/widgets/tests/conftest.py
+++ b/spyder/plugins/editor/widgets/tests/conftest.py
@@ -50,6 +50,21 @@ def codeeditor_factory():
     return editor
 
 
+def editor_factory(new_file=True, text=None):
+    editorstack = EditorStack(None, [])
+    editorstack.set_find_widget(FindReplace(editorstack))
+    editorstack.set_io_actions(Mock(), Mock(), Mock(), Mock())
+    if new_file:
+        if not text:
+            text = ('a = 1\n'
+                    'print(a)\n'
+                    '\n'
+                    'x = 2')  # a newline is added at end
+        finfo = editorstack.new('foo.py', 'utf-8', text)
+        return editorstack, finfo.editor
+    return editorstack, None
+
+
 @pytest.fixture
 def setup_editor(qtbot):
     """
@@ -57,16 +72,56 @@ def setup_editor(qtbot):
     The cursor is at the empty line below the code.
     Returns tuple with EditorStack and CodeEditor.
     """
-    text = ('a = 1\n'
-            'print(a)\n'
-            '\n'
-            'x = 2')  # a newline is added at end
-    editorStack = EditorStack(None, [])
-    editorStack.set_find_widget(FindReplace(editorStack))
-    editorStack.set_io_actions(Mock(), Mock(), Mock(), Mock())
-    finfo = editorStack.new('foo.py', 'utf-8', text)
-    qtbot.addWidget(editorStack)
-    return editorStack, finfo.editor
+    editorstack, code_editor = editor_factory()
+    qtbot.addWidget(editorstack)
+    return editorstack, code_editor
+
+
+@pytest.fixture
+def completions_editor(
+        completion_plugin_all_started, qtbot_module, request, capsys,
+        tmp_path):
+    """Editorstack instance with LSP services activated."""
+    # Create a CodeEditor instance
+    editorstack, editor = editor_factory(new_file=False)
+    qtbot_module.addWidget(editorstack)
+
+    completion_plugin, capabilities = completion_plugin_all_started
+    completion_plugin.wait_for_ms = 2000
+
+    CONF.set('completions', 'enable_code_snippets', False)
+    completion_plugin.after_configuration_update([])
+    CONF.notify_section_all_observers('completions')
+
+    file_path = tmp_path / 'test.py'
+    editor = editorstack.new(str(file_path), 'utf-8', '').editor
+    # Redirect editor LSP requests to lsp_manager
+    editor.sig_perform_completion_request.connect(
+        completion_plugin.send_request)
+
+    completion_plugin.register_file('python', str(file_path), editor)
+    editor.start_completion_services()
+    editor.register_completion_capabilities(capabilities)
+
+    with qtbot_module.waitSignal(
+            editor.completions_response_signal, timeout=30000):
+        editor.document_did_open()
+
+    def teardown():
+        editor.completion_widget.hide()
+        editor.tooltip_widget.hide()
+        editor.hide()
+
+        # Capture stderr and assert there are no errors
+        sys_stream = capsys.readouterr()
+        sys_err = sys_stream.err
+        assert sys_err == ''
+
+    request.addfinalizer(teardown)
+
+    editorstack.show()
+
+    return file_path, editorstack, editor, completion_plugin
 
 
 @pytest.fixture
diff --git a/spyder/plugins/editor/widgets/tests/test_formatting.py b/spyder/plugins/editor/widgets/tests/test_formatting.py
--- a/spyder/plugins/editor/widgets/tests/test_formatting.py
+++ b/spyder/plugins/editor/widgets/tests/test_formatting.py
@@ -13,6 +13,7 @@
 # Third party imports
 import pytest
 from qtpy.QtGui import QTextCursor
+from qtpy.QtWidgets import QMessageBox
 import yapf
 
 # Local imports
@@ -207,3 +208,46 @@ def test_max_line_length(formatter, completions_codeeditor, qtbot):
     qtbot.wait(2000)
 
     assert code_editor.get_text_with_eol() == expected
+
+
+@pytest.mark.slow
+@pytest.mark.order(1)
+@pytest.mark.parametrize('formatter', [autopep8, black])
+def test_closing_document_formatting(
+        formatter, completions_editor, qtbot, monkeypatch):
+    """Check that auto-formatting works when closing an usaved file."""
+    file_path, editorstack, code_editor, completion_plugin = completions_editor
+    text, expected = get_formatter_values(formatter, newline='\n')
+
+    # Set formatter
+    editorstack.set_format_on_save(True)
+    CONF.set(
+        'completions',
+        ('provider_configuration', 'lsp', 'values', 'formatting'),
+        formatter
+    )
+    completion_plugin.after_configuration_update([])
+    qtbot.wait(2000)
+
+    # Set text in editor
+    code_editor.set_text(text)
+
+    # Notify changes
+    with qtbot.waitSignal(
+            code_editor.completions_response_signal, timeout=30000):
+        code_editor.document_did_change()
+
+    # Perform formatting while closing the file
+    with qtbot.waitSignal(
+            code_editor.completions_response_signal, timeout=30000):
+        monkeypatch.setattr(QMessageBox, 'exec_',
+                            classmethod(lambda *args: QMessageBox.Yes))
+        monkeypatch.setattr(editorstack, 'select_savename',
+                            lambda *args: str(file_path))
+        editorstack.save_dialog_on_tests = True
+        editorstack.close_file()
+
+    # Load again formatted file and check content
+    code_editor = editorstack.load(str(file_path)).editor
+
+    assert code_editor.get_text_with_eol() == expected