Repository: spyder-ide/spyder

Issue:
The external system terminal close after running script if the Python console exits due to an exception
<!--- **PLEASE READ:** When submitting here, please ensure you've completed the following checklist and checked the boxes to confirm. Issue reports without it may be closed. Thanks! --->

### Issue Report Checklist

* [x] Searched the [issues page](https://github.com/spyder-ide/spyder/issues?q=is%3Aissue) for similar reports
* [ ] Read the relevant sections of the [Spyder Troubleshooting Guide](https://github.com/spyder-ide/spyder/wiki/Troubleshooting-Guide-and-FAQ) and followed its advice
* [x] Reproduced the issue after updating with ``conda update spyder`` (or ``pip``, if not using Anaconda)
* [x] Could not reproduce inside ``jupyter qtconsole`` (if console-related)
* [x] Tried basic troubleshooting (if a bug/error)
    * [x] Restarted Spyder
    * [x] Reset preferences with ``spyder --reset``
    * [x] Reinstalled the latest version of [Anaconda](https://www.anaconda.com/download/)
    * [x] Tried the other applicable steps from the Troubleshooting Guide
* [x] Completed the **Problem Description**, **Steps to Reproduce** and **Version** sections below


## Problem Description

Especially when testing code involving PyQt or PySide, I often like to execute my code using an `external system terminal` on Windows with the option `Interact with the Python console after execution` checked.

![image](https://user-images.githubusercontent.com/10170372/56961118-e1077a80-6b20-11e9-9239-512fda283c91.png)

However, very often when an exception is raised during execution, the Python console and the external system terminal both close, so that it is impossible to see the traceback of the error.

Below is a minimal working example I've put together that allow to reproduce this behavior on my system:

```python
import sys
from PyQt5.QtWidgets import QPushButton, QWidget, QApplication, QGridLayout


class SomeClass(QWidget):
    def __init__(self):
        super().__init__()
        layout = QGridLayout(self)
        btn_crash = QPushButton('Crash')
        btn_crash.clicked.connect(self.crash)
        btn_crash2 = QPushButton('Crash2')
        btn_crash2.clicked.connect(self.crash2)
        btn_close = QPushButton('Close')
        btn_close.clicked.connect(self.close)
        layout.addWidget(btn_crash)
        layout.addWidget(btn_crash2)
        layout.addWidget(btn_close)

    def show(self):
        super().show()

    def crash(self):
        raise ValueError

    def crash2(self):
        return self.crash2()


if __name__ == '__main__':
    app = QApplication(sys.argv)
    window = SomeClass()
    window.show()
    sys.exit(app.exec_())
``` 

![image](https://user-images.githubusercontent.com/10170372/56962727-40678980-6b25-11e9-80ee-a9cd5044821f.png)



### What steps reproduce the problem?

1. Run the script in an external system terminal in Spyder with the option  `Interact with the Python console after execution` 
2. Click on `Close` button
3. Run the script again
4. Click on `Crash` or `Crash2` button

### What is the expected output? What do you see instead?

When clicking on the `Close` button, the Python console and the terminal do not close and it is indeed possible to interact with the Python console after execution. When either the `Crash` or `Crash2` button is clicked, the Python console and the external system terminal both close, so that it is impossible to see the traceback of the error.

What I have done to go around this behavior on my system is to change the way the external system terminal is started in Windows from (see [here](https://github.com/spyder-ide/spyder/blob/d6eef76f982f29a5c71e3bac8c9bbf0b06ab56ea/spyder/utils/programs.py#L294)) :

```python
cmd = 'start cmd.exe /c "cd %s && ' % wdir + ' '.join(p_args) + '"'
```
to (see https://stackoverflow.com/a/46882828/4481445) :
```python
cmd = 'start cmd.exe /K "cd %s && ' % wdir + ' '.join(p_args) + '"' + ' ^&^& exit'
```
This prevents the external system terminal from closing when the Python console exit with an error code other than 0. If there is no error though, then the terminal is closed automatically like before. 

## Versions
<!--- You can get this information from Help > About Spyder...
or (if Spyder won't launch) the "conda list" command
from the Anaconda Prompt/Terminal/command line. --->

* Spyder version: Spyder 4.0.0.dev0 
* Python version: Python 3.7.1 64-bit
* Qt version: Qt 5.9.6
* PyQt version: PyQt5 5.9.2
* Operating System name/version: Windows 10



Discussion:
@jnsebgosselin I guess this is also happening for 3.x?
> @jnsebgosselin I guess this is also happening for 3.x?

Yes, It should do the same thing in 3.x. However, I do not consider the current behavior to be a bug per se, though I think that implementing the change I'm proposing, either as the default or as an additional option, would be a great Ux-usability improvement.
I think this could go then on the 3.x series @ccordoba12 ?

>Yes, It should do the same thing in 3.x. However, I do not consider the current behavior to be a bug per se, though I think that implementing the change I'm proposing, either as the default 

I would prefer default, I do not see how useful is the current behavior, nor providing an option for that (also it makes it easier to bubble up the change to master :-p)

But I would like to hear opinions :-) @spyder-ide/core-developers 
I agree, @goanpeca ; I don't see how immediately closing the terminal window if an error occurs (thus hiding the error, and possibly even that an error occurred at all) would ever be what a user would want or expect. In the worst case, they can spend a second closing it themselves, which is nothing compared to the cost of the alternative in the most likely case.
@jnsebgosselin could you work on a fix aimed at the 3.x branch? No configuration needed :-)
Cool, I'll prepare a PR  then.

Resolving patch:
diff --git a/spyder/utils/programs.py b/spyder/utils/programs.py
--- a/spyder/utils/programs.py
+++ b/spyder/utils/programs.py
@@ -291,7 +291,10 @@ def run_python_script_in_terminal(fname, wdir, args, interact,
     p_args += get_python_args(fname, python_args, interact, debug, args)
 
     if os.name == 'nt':
-        cmd = 'start cmd.exe /c "cd %s && ' % wdir + ' '.join(p_args) + '"'
+        cmd = 'start cmd.exe /K "'
+        if wdir:
+            cmd += 'cd ' + wdir + ' && '
+        cmd += ' '.join(p_args) + '"' + ' ^&^& exit'
         # Command line and cwd have to be converted to the filesystem
         # encoding before passing them to subprocess, but only for
         # Python 2.
@@ -300,7 +303,10 @@ def run_python_script_in_terminal(fname, wdir, args, interact,
             cmd = encoding.to_fs_from_unicode(cmd)
             wdir = encoding.to_fs_from_unicode(wdir)
         try:
-            run_shell_command(cmd, cwd=wdir)
+            if wdir:
+                run_shell_command(cmd, cwd=wdir)
+            else:
+                run_shell_command(cmd)
         except WindowsError:
             from qtpy.QtWidgets import QMessageBox
             from spyder.config.base import _


Test patch:
diff --git a/spyder/utils/tests/test_programs.py b/spyder/utils/tests/test_programs.py
--- a/spyder/utils/tests/test_programs.py
+++ b/spyder/utils/tests/test_programs.py
@@ -6,6 +6,7 @@
 """Tests for programs.py"""
 
 import os
+import os.path as osp
 import sys
 
 from flaky import flaky
@@ -35,6 +36,22 @@
     INVALID_INTERPRETER = os.path.join(home_dir, 'miniconda', 'bin', 'ipython')
 
 
+# =============================================================================
+# ---- Fixtures
+# =============================================================================
+@pytest.fixture
+def scriptpath(tmpdir):
+    """Save a basic Python script in a file."""
+    script = ("with open('out.txt', 'w') as f:\n"
+              "    f.write('done')\n")
+    scriptpath = tmpdir.join('write-done.py')
+    scriptpath.write(script)
+    return scriptpath
+
+
+# =============================================================================
+# ---- Tests
+# =============================================================================
 @pytest.mark.skipif((sys.platform.startswith('linux') or
                      os.environ.get('CI', None) is None),
                     reason='It only runs in CI services and '
@@ -44,35 +61,43 @@ def test_is_valid_w_interpreter():
 
 
 @flaky(max_runs=3)
-@pytest.mark.skipif((os.name == 'nt' or os.environ.get('CI', None) is None or
-                     sys.platform == 'darwin'),
-                    reason='gets stuck on Windows and fails in macOS and sometimes locally') # FIXME
-def test_run_python_script_in_terminal(tmpdir, qtbot):
-    scriptpath = tmpdir.join('write-done.py')
-    outfilepath = tmpdir.join('out.txt')
-    script = ("with open('out.txt', 'w') as f:\n"
-              "    f.write('done')\n")
-    scriptpath.write(script)
-    run_python_script_in_terminal(scriptpath.strpath, tmpdir.strpath, '',
-                                  False, False, '')
-    qtbot.wait(1000) # wait for script to finish
-    res = outfilepath.read()
+@pytest.mark.skipif(
+    os.environ.get('CI', None) is None or sys.platform == 'darwin',
+    reason='fails in macOS and sometimes locally')
+def test_run_python_script_in_terminal(scriptpath, qtbot):
+    """
+    Test running a Python script in an external terminal when specifying
+    explicitely the working directory.
+    """
+    # Run the script.
+    outfilepath = osp.join(scriptpath.dirname, 'out.txt')
+    run_python_script_in_terminal(
+        scriptpath.strpath, scriptpath.dirname, '', False, False, '')
+    qtbot.waitUntil(lambda: osp.exists(outfilepath), timeout=1000)
+
+    # Assert the result.
+    with open(outfilepath, 'r') as txtfile:
+        res = txtfile.read()
     assert res == 'done'
 
 
 @flaky(max_runs=3)
-@pytest.mark.skipif((os.name == 'nt' or os.environ.get('CI', None) is None or
-                     sys.platform == 'darwin'),
-                    reason='gets stuck on Windows and fails in macOS and sometimes locally') # FIXME
-def test_run_python_script_in_terminal_with_wdir_empty(tmpdir, qtbot):
-    scriptpath = tmpdir.join('write-done.py')
-    outfilepath = tmpdir.join('out.txt')
-    script = ("with open('{}', 'w') as f:\n"
-              "    f.write('done')\n").format(outfilepath.strpath)
-    scriptpath.write(script)
+@pytest.mark.skipif(
+    os.environ.get('CI', None) is None or sys.platform == 'darwin',
+    reason='fails in macOS and sometimes locally')
+def test_run_python_script_in_terminal_with_wdir_empty(scriptpath, qtbot):
+    """
+    Test running a Python script in an external terminal without specifying
+    the working directory.
+    """
+    # Run the script.
+    outfilepath = osp.join(os.getcwd(), 'out.txt')
     run_python_script_in_terminal(scriptpath.strpath, '', '', False, False, '')
-    qtbot.wait(1000) # wait for script to finish
-    res = outfilepath.read()
+    qtbot.waitUntil(lambda: osp.exists(outfilepath), timeout=1000)
+
+    # Assert the result.
+    with open(outfilepath, 'r') as txtfile:
+        res = txtfile.read()
     assert res == 'done'