Skip to content

Commit

Permalink
Merge pull request #14025 from impact27/std_files
Browse files Browse the repository at this point in the history
PR: Fix handling of kernel stderr, and capture stdout and segfaults too (IPython console)
  • Loading branch information
ccordoba12 committed Nov 17, 2021
2 parents 4e15ee0 + b4dfd7d commit f20053f
Show file tree
Hide file tree
Showing 6 changed files with 363 additions and 112 deletions.
69 changes: 69 additions & 0 deletions spyder/app/tests/test_mainwindow.py
Original file line number Diff line number Diff line change
Expand Up @@ -4233,5 +4233,74 @@ def test_add_external_plugins_to_dependencies(main_window):
assert 'spyder-boilerplate' in external_names


@pytest.mark.slow
@flaky(max_runs=3)
def test_print_multiprocessing(main_window, qtbot, tmpdir):
"""Test print commands from multiprocessing."""
# Write code with a cell to a file
code = """
import multiprocessing
import sys
def test_func():
print("Test stdout")
print("Test stderr", file=sys.stderr)
if __name__ == "__main__":
p = multiprocessing.Process(target=test_func)
p.start()
p.join()
"""

p = tmpdir.join("print-test.py")
p.write(code)
main_window.editor.load(to_text_string(p))
shell = main_window.ipyconsole.get_current_shellwidget()
qtbot.waitUntil(lambda: shell._prompt_html is not None,
timeout=SHELL_TIMEOUT)
control = main_window.ipyconsole.get_widget().get_focus_widget()

# Click the run button
run_action = main_window.run_toolbar_actions[0]
run_button = main_window.run_toolbar.widgetForAction(run_action)
with qtbot.waitSignal(shell.executed):
qtbot.mouseClick(run_button, Qt.LeftButton)
qtbot.wait(1000)

assert 'Test stdout' in control.toPlainText()
assert 'Test stderr' in control.toPlainText()


@pytest.mark.slow
@flaky(max_runs=3)
@pytest.mark.skipif(
os.name == 'nt',
reason="ctypes.string_at(0) doesn't segfaults on Windows")
def test_print_faulthandler(main_window, qtbot, tmpdir):
"""Test printing segfault info from kernel crashes."""
# Write code with a cell to a file
code = """
def crash_func():
import ctypes; ctypes.string_at(0)
crash_func()
"""

p = tmpdir.join("print-test.py")
p.write(code)
main_window.editor.load(to_text_string(p))
shell = main_window.ipyconsole.get_current_shellwidget()
qtbot.waitUntil(lambda: shell._prompt_html is not None,
timeout=SHELL_TIMEOUT)
control = main_window.ipyconsole.get_widget().get_focus_widget()

# Click the run button
run_action = main_window.run_toolbar_actions[0]
run_button = main_window.run_toolbar.widgetForAction(run_action)
qtbot.mouseClick(run_button, Qt.LeftButton)
qtbot.wait(5000)

assert 'Segmentation fault' in control.toPlainText()
assert 'in crash_func' in control.toPlainText()


if __name__ == "__main__":
pytest.main()
10 changes: 5 additions & 5 deletions spyder/plugins/ipythonconsole/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ def on_initialize(self):
self.main.sig_pythonpath_changed.connect(self.update_path)

self.sig_focus_changed.connect(self.main.plugin_focus_changed)
self._remove_old_stderr_files()
self._remove_old_std_files()

@on_plugin_available(plugin=Plugins.Preferences)
def on_preferences_available(self):
Expand Down Expand Up @@ -405,17 +405,17 @@ def _on_project_loaded(self):
def _on_project_closed(self):
self.get_widget().update_active_project_path(None)

def _remove_old_stderr_files(self):
def _remove_old_std_files(self):
"""
Remove stderr files left by previous Spyder instances.
Remove std files left by previous Spyder instances.
This is only required on Windows because we can't
clean up stderr files while Spyder is running on it.
clean up std files while Spyder is running on it.
"""
if os.name == 'nt':
tmpdir = get_temp_dir()
for fname in os.listdir(tmpdir):
if osp.splitext(fname)[1] == '.stderr':
if osp.splitext(fname)[1] in ('.stderr', '.stdout', '.fault'):
try:
os.remove(osp.join(tmpdir, fname))
except Exception:
Expand Down
61 changes: 45 additions & 16 deletions spyder/plugins/ipythonconsole/tests/test_ipythonconsole.py
Original file line number Diff line number Diff line change
Expand Up @@ -878,10 +878,10 @@ def test_read_stderr(ipyconsole, qtbot):

# Set contents of the stderr file of the kernel
content = 'Test text'
stderr_file = client.stderr_file
stderr_file = client.stderr_obj.filename
codecs.open(stderr_file, 'w', 'cp437').write(content)
# Assert that content is correct
assert content == client._read_stderr()
assert content == client.stderr_obj.get_contents()


@flaky(max_runs=10)
Expand Down Expand Up @@ -1267,9 +1267,9 @@ def test_stderr_file_is_removed_one_kernel(ipyconsole, qtbot, monkeypatch):
# In a normal situation file should exist
monkeypatch.setattr(QMessageBox, 'question',
classmethod(lambda *args: QMessageBox.Yes))
assert osp.exists(client.stderr_file)
assert osp.exists(client.stderr_obj.filename)
ipyconsole.close_client(client=client)
assert not osp.exists(client.stderr_file)
assert not osp.exists(client.stderr_obj.filename)


@flaky(max_runs=3)
Expand All @@ -1288,14 +1288,14 @@ def test_stderr_file_is_removed_two_kernels(ipyconsole, qtbot, monkeypatch):
client.connection_file, None, None, None)
assert len(ipyconsole.get_widget().get_related_clients(client)) == 1
other_client = ipyconsole.get_widget().get_related_clients(client)[0]
assert client.stderr_file == other_client.stderr_file
assert client.stderr_obj.filename == other_client.stderr_obj.filename

# In a normal situation file should exist
monkeypatch.setattr(QMessageBox, 'question',
classmethod(lambda *args: QMessageBox.Yes))
assert osp.exists(client.stderr_file)
assert osp.exists(client.stderr_obj.filename)
ipyconsole.close_client(client=client)
assert not osp.exists(client.stderr_file)
assert not osp.exists(client.stderr_obj.filename)


@flaky(max_runs=3)
Expand All @@ -1315,14 +1315,14 @@ def test_stderr_file_remains_two_kernels(ipyconsole, qtbot, monkeypatch):

assert len(ipyconsole.get_widget().get_related_clients(client)) == 1
other_client = ipyconsole.get_widget().get_related_clients(client)[0]
assert client.stderr_file == other_client.stderr_file
assert client.stderr_obj.filename == other_client.stderr_obj.filename

# In a normal situation file should exist
monkeypatch.setattr(QMessageBox, "question",
classmethod(lambda *args: QMessageBox.No))
assert osp.exists(client.stderr_file)
assert osp.exists(client.stderr_obj.filename)
ipyconsole.close_client(client=client)
assert osp.exists(client.stderr_file)
assert osp.exists(client.stderr_obj.filename)


@flaky(max_runs=3)
Expand Down Expand Up @@ -1361,15 +1361,15 @@ def test_kernel_crash(ipyconsole, qtbot):

@flaky(max_runs=3)
@pytest.mark.skipif(not os.name == 'nt', reason="Only works on Windows")
def test_remove_old_stderr_files(ipyconsole, qtbot):
"""Test that we are removing old stderr files."""
def test_remove_old_std_files(ipyconsole, qtbot):
"""Test that we are removing old std files."""
# Create empty stderr file in our temp dir to see
# if it's removed correctly.
tmpdir = get_temp_dir()
open(osp.join(tmpdir, 'foo.stderr'), 'a').close()

# Assert that only that file is removed
ipyconsole._remove_old_stderr_files()
ipyconsole._remove_old_std_files()
assert not osp.isfile(osp.join(tmpdir, 'foo.stderr'))


Expand Down Expand Up @@ -1753,11 +1753,40 @@ def test_stderr_poll(ipyconsole, qtbot):
qtbot.waitUntil(lambda: shell._prompt_html is not None,
timeout=SHELL_TIMEOUT)
client = ipyconsole.get_current_client()
with open(client.stderr_file, 'w') as f:
client.stderr_obj.handle.flush()
with open(client.stderr_obj.filename, 'a') as f:
f.write("test_test")
# Wait for the poll
qtbot.wait(2000)
assert "test_test" in ipyconsole.get_widget().get_focus_widget().toPlainText()
qtbot.waitUntil(lambda: "test_test" in ipyconsole.get_widget(
).get_focus_widget().toPlainText())
assert "test_test" in ipyconsole.get_widget(
).get_focus_widget().toPlainText()
# Write a second time, makes sure it is not duplicated
client.stderr_obj.handle.flush()
with open(client.stderr_obj.filename, 'a') as f:
f.write("\ntest_test")
# Wait for the poll
qtbot.waitUntil(lambda: ipyconsole.get_widget().get_focus_widget(
).toPlainText().count("test_test") == 2)
assert ipyconsole.get_widget().get_focus_widget().toPlainText(
).count("test_test") == 2


@flaky(max_runs=3)
def test_stdout_poll(ipyconsole, qtbot):
"""Test if the content of stdout is printed to the console."""
shell = ipyconsole.get_current_shellwidget()
qtbot.waitUntil(lambda: shell._prompt_html is not None,
timeout=SHELL_TIMEOUT)
client = ipyconsole.get_current_client()
client.stdout_obj.handle.flush()
with open(client.stdout_obj.filename, 'a') as f:
f.write("test_test")
# Wait for the poll
qtbot.waitUntil(lambda: "test_test" in ipyconsole.get_widget(
).get_focus_widget().toPlainText(), timeout=5000)
assert "test_test" in ipyconsole.get_widget().get_focus_widget(
).toPlainText()


@flaky(max_runs=10)
Expand Down
92 changes: 92 additions & 0 deletions spyder/plugins/ipythonconsole/utils/stdfile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# -*- coding: utf-8 -*-
# -----------------------------------------------------------------------------
# Copyright (c) 2009- Spyder Project Contributors
#
# Distributed under the terms of the MIT License
# (see spyder/__init__.py for details)
# -----------------------------------------------------------------------------
"""
Class to control a file where stanbdard output can be written.
"""
# Standard library imports.
import codecs
import os

# Local imports
from spyder.py3compat import to_text_string
from spyder.utils.encoding import get_coding


class StdFile():
def __init__(self, filename):
self.filename = filename
self._mtime = 0
self._cursor = 0
self._handle = None

@property
def handle(self):
"""Get handle to file."""
if self._handle is None and self.filename is not None:
# Needed to prevent any error that could appear.
# See spyder-ide/spyder#6267.
try:
self._handle = codecs.open(
self.filename, 'w', encoding='utf-8')
except Exception:
pass
return self._handle

def remove(self):
"""Remove file associated with the client."""
try:
# Defer closing the handle until the client
# is closed because jupyter_client needs it open
# while it tries to restart the kernel
if self._handle is not None:
self._handle.close()
os.remove(self.filename)
self._handle = None
except Exception:
pass

def get_contents(self):
"""Get the contents of the std kernel file."""
try:
with open(self.filename, 'rb') as f:
# We need to read the file as bytes to be able to
# detect its encoding with chardet
text = f.read()

# This is needed to avoid showing an empty error message
# when the kernel takes too much time to start.
# See spyder-ide/spyder#8581.
if not text:
return ''

# This is needed since the file could be encoded
# in something different to utf-8.
# See spyder-ide/spyder#4191.
encoding = get_coding(text)
text = to_text_string(text, encoding)
return text
except Exception:
return None

def poll_file_change(self):
"""Check if the std kernel file just changed."""
if self._handle is not None and not self._handle.closed:
self._handle.flush()
try:
mtime = os.stat(self.filename).st_mtime
except Exception:
return

if mtime == self._mtime:
return
self._mtime = mtime
text = self.get_contents()
if text:
ret_text = text[self._cursor:]
self._cursor = len(text)
return ret_text
Loading

0 comments on commit f20053f

Please sign in to comment.