Repository: spyder-ide/spyder
Pull request: PR: Save files when calling runfile directly in the console
<!--- Make sure to read the Contributing Guidelines:                   --->
<!--- https://github.com/spyder-ide/spyder/blob/master/CONTRIBUTING.md --->
<!--- and follow PEP 8, PEP 257 and Spyder's code style:               --->
<!--- https://github.com/spyder-ide/spyder/wiki/Dev:-Coding-Style      --->

## Description of Changes
Save files before running when running files from the kernel

* [ ] Wrote at least one-line docstrings (for any new functions)
* [ ] Added unit test(s) covering the changes (if testable)
<!--- Remember that an image/animation is worth a thousand words! --->
* [ ] Included a screenshot or animation (if affecting the UI, see [Licecap](https://www.cockos.com/licecap/))


<!--- Explain what you've done and why --->




### Issue(s) Resolved

<!--- List the issue(s) below, in the form "Fixes #1234"; one per line --->

Fixes #9342


### Affirmation

By submitting this Pull Request or typing my (user)name below,
I affirm the [Developer Certificate of Origin](https://developercertificate.org)
with respect to all commits and content included in this PR,
and understand I am releasing the same under Spyder's MIT (Expat) license.

<!--- TYPE YOUR USER/NAME AFTER THE FOLLOWING: --->
I certify the above statement is true and correct:

<!--- Thanks for your help making Spyder better for everyone! --->


Problem statement:
File not saved when calling runfile from console
## Description

### What steps will reproduce the problem?

<!--- You can use Markdown here --->

When pressing the green "Run file" arrow, the file is automatically saved. It would be cool if the same appened when I type `runfile('file.py')` in the console.

## Versions

* Spyder version: 4.0.0.dev0 f1daab7a4
* Python version: 3.7.3
* Qt version: 5.9.1
* PyQt5 version: 5.9
* Operating System: Darwin 18.5.0

### Dependencies

```
pygments >=2.0     :  2.3.1 (OK)
qdarkstyle >=2.6.4 :  2.6.5 (OK)
sphinx >=0.6.6     :  1.8.5 (OK)
psutil >=0.3       :  5.6.1 (OK)
pyls >=0.19.0;<0.25:  0.25.0 (NOK)
nbconvert >=4.0    :  5.4.1 (OK)
pandas >=0.13.1    :  0.24.2 (OK)
numpy >=1.7        :  1.16.2 (OK)
sympy >=0.7.3      :  1.3 (OK)
cython >=0.21      :  0.29.6 (OK)
qtconsole >=4.2.0  :  4.5.0.dev0 (OK)
IPython >=4.0      :  7.3.0 (OK)
matplotlib >=2.0.0 :  3.0.3 (OK)
pylint >=0.25      :  2.3.1 (OK)
```

Hints from issue discussion:
We really need to add Comms to qtconsole to expand the things we can do between the Spyder interface and the kernel.

Trying to do something else is brittle and hacky.

Commit message: Use PR 136

Patch to review:
diff --git a/continuous_integration/azure/install.bat b/continuous_integration/azure/install.bat
index 82c305af19d..42e78cb714e 100644
--- a/continuous_integration/azure/install.bat
+++ b/continuous_integration/azure/install.bat
@@ -26,7 +26,7 @@ if %USE_CONDA% == yes (
 )
 
 :: Install spyder-kernels from master
-pip install -q --no-deps git+https://github.com/spyder-ide/spyder-kernels
+pip install -q --no-deps git+https://github.com/spyder-ide/spyder-kernels.git@refs/pull/136/head
 if errorlevel 1 exit 1
 
 :: Install codecov
diff --git a/continuous_integration/azure/install.sh b/continuous_integration/azure/install.sh
index 7d865915bd1..b26840fa4ff 100755
--- a/continuous_integration/azure/install.sh
+++ b/continuous_integration/azure/install.sh
@@ -9,7 +9,7 @@ if [ "$USE_CONDA" = "yes" ]; then
     conda install -q -y -c spyder-ide --file requirements/tests.txt
 
     # Install spyder-kernels from Github with no deps
-    pip install -q --no-deps git+https://github.com/spyder-ide/spyder-kernels
+    pip install -q --no-deps git+https://github.com/spyder-ide/spyder-kernels.git@refs/pull/136/head
 else
     # Update pip and setuptools
     pip install -U pip setuptools
@@ -27,5 +27,5 @@ else
     pip install git+https://github.com/jupyter/qtconsole.git
 
     # Install spyder-kernels from Github
-    pip install -q git+https://github.com/spyder-ide/spyder-kernels
+    pip install -q git+https://github.com/spyder-ide/spyder-kernels.git@refs/pull/136/head
 fi
diff --git a/continuous_integration/travis/install.sh b/continuous_integration/travis/install.sh
index 8a9e85a7d1b..a8ea590df43 100755
--- a/continuous_integration/travis/install.sh
+++ b/continuous_integration/travis/install.sh
@@ -27,7 +27,7 @@ if [ "$USE_CONDA" = "yes" ]; then
     pip install -q coveralls
 
     # Install spyder-kernels from Github with no deps
-    pip install -q --no-deps git+https://github.com/spyder-ide/spyder-kernels
+    pip install -q --no-deps git+https://github.com/spyder-ide/spyder-kernels.git@refs/pull/136/head
 else
     # Downgrade to Python 3.7.3 because 3.7.4 is not pulling
     # wheels for all packages
@@ -54,7 +54,7 @@ else
     pip install git+https://github.com/jupyter/qtconsole.git
 
     # Install spyder-kernels from Github
-    pip install -q git+https://github.com/spyder-ide/spyder-kernels
+    pip install -q git+https://github.com/spyder-ide/spyder-kernels.git@refs/pull/136/head
 
     # Install coveralls
     pip install -q coveralls
diff --git a/spyder/app/tests/test_mainwindow.py b/spyder/app/tests/test_mainwindow.py
index 7f2088124a1..cf7d69f8d45 100644
--- a/spyder/app/tests/test_mainwindow.py
+++ b/spyder/app/tests/test_mainwindow.py
@@ -54,6 +54,7 @@
 from spyder.py3compat import PY2, to_text_string
 from spyder.utils.programs import is_module_installed
 from spyder.widgets.dock import DockTitleBar
+from spyder.utils.misc import remove_backslashes
 
 # For testing various Spyder urls
 if not PY2:
@@ -1821,6 +1822,31 @@ def test_custom_layouts(main_window, qtbot):
                                 print(widget)  # spyder: test-skip
                                 assert widget.isVisible()
 
+# @pytest.mark.slow
+@flaky(max_runs=3)
+def test_save_on_runfile(main_window, qtbot):
+    """Test that layout are showing the expected widgets visible."""
+    # Load test file
+    test_file = osp.join(LOCATION, 'script.py')
+    test_file_copy = test_file[:-3] + '_copy.py'
+    shutil.copyfile(test_file, test_file_copy)
+    main_window.editor.load(test_file_copy)
+    code_editor = main_window.editor.get_focus_widget()
+
+    # Verify result
+    shell = main_window.ipyconsole.get_current_shellwidget()
+    qtbot.waitUntil(lambda: shell._prompt_html is not None,
+                    timeout=SHELL_TIMEOUT)
+
+    qtbot.keyClicks(code_editor, 'test_var = 123', delay=100)
+    filename = code_editor.filename
+    with qtbot.waitSignal(shell.sig_prompt_ready):
+        shell.execute('runfile("{}")'.format(remove_backslashes(filename)))
+
+    assert shell.get_value('test_var') == 123
+    main_window.editor.close_file()
+    os.remove(test_file_copy)
+
 
 # @pytest.mark.slow
 def test_pylint_follows_file(qtbot, tmpdir, main_window):
diff --git a/spyder/plugins/ipythonconsole/plugin.py b/spyder/plugins/ipythonconsole/plugin.py
index 9dd1961d530..f8a8d9ff02a 100644
--- a/spyder/plugins/ipythonconsole/plugin.py
+++ b/spyder/plugins/ipythonconsole/plugin.py
@@ -941,6 +941,12 @@ def register_client(self, client, give_focus=True):
             page_control.visibility_changed.connect(self.refresh_plugin)
             page_control.show_find_widget.connect(self.find_widget.show)
 
+        def editorstack():
+            """Get current editor stack."""
+            return self.main.editor.get_current_editorstack()
+
+        shellwidget.editorstack = editorstack
+
     def close_client(self, index=None, client=None, force=False):
         """Close client tab from index or widget (or close current tab)"""
         if not self.tabwidget.count():
diff --git a/spyder/plugins/ipythonconsole/widgets/shell.py b/spyder/plugins/ipythonconsole/widgets/shell.py
index 4daef8516f6..696882bb900 100644
--- a/spyder/plugins/ipythonconsole/widgets/shell.py
+++ b/spyder/plugins/ipythonconsole/widgets/shell.py
@@ -98,6 +98,7 @@ def __init__(self, ipyclient, additional_options, interpreter_versions,
             'pdb_state': self.set_pdb_state,
             'pdb_continue': self.pdb_continue,
             'get_breakpoints': self.get_spyder_breakpoints,
+            'savefiles': self.handle_kernel_api_savefiles,
         }
 
         for request_id in handlers:
@@ -452,6 +453,26 @@ def change_mpl_backend(self, command):
             if not 'inline' in command:
                 self.silent_execute(command)
 
+    # ---- Spyder-kernels methods -------------------------------------------
+    def get_editor(self, filename=None):
+        """Get editor for filename and set it as the current editor."""
+        editorstack = self.editorstack()
+
+        if not filename:
+            return editorstack.get_current_editor()
+
+        index = editorstack.has_filename(filename)
+        if index is None:
+            return None
+
+        editor = editorstack.data[index].editor
+        editorstack.set_stack_index(index)
+        return editor
+
+    def handle_kernel_api_savefiles(self):
+        """Save the open files."""
+        self.editorstack().save()
+
     # ---- Private methods (overrode by us) ---------------------------------
 
     def _handle_error(self, msg):


Code review comments:
diff_hunk: @@ -452,6 +453,26 @@ def change_mpl_backend(self, command):
             if not 'inline' in command:
                 self.silent_execute(command)
 
+    # ---- Spyder-kernels methods -------------------------------------------
+    def get_editor(self, filename=None):
+        """Get editor for filename and set it as the current editor."""
+        editorstack = self.editorstack()
+
+        if not filename:
+            return editorstack.get_current_editor()
+
+        index = editorstack.has_filename(filename)
+        if index is None:
+            return None
+
+        editor = editorstack.data[index].editor
+        editorstack.set_stack_index(index)
+        return editor
+
+    def handle_kernel_api_savefiles(self):
+        """Save the open files."""
+        self.editorstack().save()
path: spyder/plugins/ipythonconsole/widgets/shell.py
text: @user1:
This is not the way we decided to handle interactions between plugins: if a widget from one plugin needs to call another plugin, it needs to emit a Qt signal, then that signal must be handled by its parent plugin, which asks the other plugin to perform the desired action.

So in this case you need to emit a signal at this point (say `sig_save_file`) with the filename you want to save. Then you need to connect that signal to a slot in `register_client` of `plugins/ipythonconsole/plugin.py` (say `save_runfile`). Since that slot is at the plugin level, it can call directly `self.main.editor` to detect if `filename` is open there, then call the current editorstack to save it.

@author:
Are the Qt signals syncronous? The goal here is to save the file before resuming execution. 

@user1:
Right, then you need to use a QEventLoop to make it synchronous.

@author:
Does using a `QEventLoop` to make a blocking call not kind of defeat the point of using Qt signals? I mean I can either:
 - Call a method of the plugin and use the return value.
 - Emit a signal to the plugin that will then emit a signal with the reply, while I have a `QEventLoop` running, inplement a mechanism to save the reply (Another method and a class attribute?), implement a mechanism to stop the `QEventLoop` when the reply is recieved (Probably just connecting the reply signal to the Slot of `QEventLoop`), and implement a mechanism to get the reply after the `QEventLoop` finishes.

The second one seems to add quite a lot of complexity to the code. What is the adventage of this approach? I mean in general I would agree that for async calls Qt signals are superior, but I am not so sure about sync calls. Is there anywhere else in the code where a sync call is made with a `QEventLoop`? The only place I can think of is the communication with the kernel, but there a direct call was not possible.

@user1:
> The second one seems to add quite a lot of complexity to the code

Yeah, you're right. As soon as I wrote you about using a QEventLoop, I realized it makes things way too complex.

Then let's change this to the following:

```python
plugin = self.ipyclient.plugin
if plugin.main.editor is not None:
    editor = plugin.main.editor
    editorstack = editor.get_current_editorstack()
    editorstack.save()
```

For this to work you also need to create an attribute called `plugin` in `ClientWidget` that holds the arg `plugin` passed to it.