Skip to content

Commit 9347d83

Browse files
committed
Better handling of writes to __stderr__/__stdout__
On windows, if using pythonw.exe these streams are set to None. If you try to print to None it will write to `sys.stdout` which causes recursion. The intent of these calls is to write to the host shell when errors happen processing a normal PrEditor write so the problem can be debugged. If you run into that issue when using pythonw.exe you will need to switch to python.exe to see the debug output.
1 parent 44ec69d commit 9347d83

File tree

4 files changed

+155
-18
lines changed

4 files changed

+155
-18
lines changed

preditor/excepthooks.py

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
from Qt import QtCompat
77

8-
from . import config, plugins
8+
from . import config, plugins, utils
99
from .contexts import ErrorReport
1010
from .weakref import WeakList
1111

@@ -49,9 +49,7 @@ def __call__(self, *exc_info):
4949
# prevent showing the traceback normally. This last ditch method prints
5050
# the traceback to the original stderr stream so it can be debugged.
5151
# Without this you might get little to no output to work with.
52-
print(" PrEditor excepthooks failed ".center(79, "-"), file=sys.__stderr__)
53-
traceback.print_exc(file=sys.__stderr__)
54-
print(" PrEditor excepthooks failed ".center(79, "-"), file=sys.__stderr__)
52+
utils.ShellPrint(True).print_exc("PrEditor excepthooks failed")
5553

5654
def default_excepthooks(self):
5755
"""Returns default excepthooks handlers.
@@ -90,15 +88,7 @@ def call_callbacks(cls, *exc_info):
9088
try:
9189
callback(*exc_info)
9290
except Exception:
93-
print(
94-
" PrEditor excepthook callback failed ".center(79, "-"),
95-
file=sys.__stderr__,
96-
)
97-
traceback.print_exc(file=sys.__stderr__)
98-
print(
99-
" PrEditor excepthook callback failed ".center(79, "-"),
100-
file=sys.__stderr__,
101-
)
91+
utils.ShellPrint(True).print_exc("PrEditor excepthook callback failed")
10292

10393
def ask_to_show_logger(self, *exc_info):
10494
"""Show a dialog asking the user how to handle the error."""

preditor/stream/manager.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
from __future__ import absolute_import, print_function
22

33
import collections
4-
import sys
5-
import traceback
64

5+
from .. import utils
76
from ..weakref import WeakList
87

98

@@ -95,6 +94,4 @@ def write(self, msg, state):
9594
try:
9695
callback(msg, state)
9796
except Exception:
98-
print(" PrEditor Console failed ".center(79, "-"), file=sys.__stderr__)
99-
traceback.print_exc(file=sys.__stderr__)
100-
print(" PrEditor Console failed ".center(79, "-"), file=sys.__stderr__)
97+
utils.ShellPrint(True).print_exc("PrEditor Console failed")

preditor/utils/__init__.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import json
33
import os
44
import sys
5+
import traceback
56
from pathlib import Path
67

78

@@ -99,6 +100,63 @@ def loads_json(cls, json_str, source):
99100
return cls._load_json(source, json.loads, json_str)
100101

101102

103+
class ShellPrint:
104+
"""Utilities to print to sys.__stdout__/__stderr__ bypassing the PrEditor streams.
105+
106+
This allows you to write to the host console instead of PrEditor's interface.
107+
This helps when developing for the stream or console classes if you cause an
108+
exception you might get no traceback printed to debug otherwise.
109+
110+
On windows if using gui mode app(pythonw.exe) that doesn't have a console
111+
the methods will not print anything and just return False. If possible switch
112+
to using python.exe or install another file stream like `preditor.debug.FileLogger`,
113+
but they will need to be installed on `sys.__stdout__/__stderr__`.
114+
"""
115+
116+
def __init__(self, error=False):
117+
self.error = error
118+
119+
def print(self, *args, **kwargs):
120+
"""Prints to the shell."""
121+
if "file" in kwargs:
122+
raise KeyError(
123+
"file can not be passed to `ShellPrint.print`. Instead use error."
124+
)
125+
# Check for pythonw.exe's lack of streams and exit
126+
kwargs["file"] = self.stream
127+
if kwargs["file"] is None:
128+
"""Note: This protects against errors like this when using pythonw
129+
File "preditor/stream/director.py", line 124, in write
130+
super().write(msg)
131+
RuntimeError: reentrant call inside <_io.BufferedWriter name='nul'>
132+
"""
133+
return False
134+
135+
# Print to the host stream
136+
print(*args, **kwargs)
137+
return True
138+
139+
def print_exc(self, msg, limit=None, chain=True, width=79):
140+
"""Prints a header line, the current exception and a footer line to shell.
141+
142+
This must be called from inside of a try/except statement.
143+
"""
144+
stream = self.stream
145+
if stream is None:
146+
return False
147+
148+
print(f" {msg} ".center(width, "-"), file=sys.__stderr__)
149+
traceback.print_exc(limit=limit, file=stream, chain=chain)
150+
print(f" {msg} ".center(width, "-"), file=sys.__stderr__)
151+
return True
152+
153+
@property
154+
def stream(self):
155+
if self.error:
156+
return sys.__stderr__
157+
return sys.__stdout__
158+
159+
102160
class Truncate:
103161
def __init__(self, text, sep='...'):
104162
self.text = text

tests/test_stream.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
import io
44
import logging
55
import sys
6+
import traceback
67

78
import pytest
89

10+
from preditor import utils
911
from preditor.constants import StreamType
1012
from preditor.stream import Director, Manager, install_to_std
1113

@@ -275,3 +277,93 @@ def test_handler_info(input, check):
275277
# Treat None as the default formatter
276278
check[3] = HandlerInfo._default_format
277279
assert hi.formatter._fmt == check[3]
280+
281+
282+
class TestShellPrint:
283+
def patch(self, monkeypatch, pyw):
284+
# Replace all 4 streams with tracking streams
285+
new_out = io.StringIO()
286+
new_err = io.StringIO()
287+
if pyw:
288+
# or None if simulating pythonw
289+
new_out_ = None
290+
new_err_ = None
291+
else:
292+
# If the console exists a stream is actually used
293+
new_out_ = io.StringIO()
294+
new_err_ = io.StringIO()
295+
296+
monkeypatch.setattr(sys, "stdout", new_out)
297+
monkeypatch.setattr(sys, "stderr", new_err)
298+
monkeypatch.setattr(sys, "__stdout__", new_out_)
299+
monkeypatch.setattr(sys, "__stderr__", new_err_)
300+
301+
return new_out, new_err, new_out_, new_err_
302+
303+
def test_print_none(self, monkeypatch, capsys):
304+
"""Verify that the print command works as expected. If `sys.__std*__`
305+
is None (pythonw.exe on windows) it will write to `sys.stdout`.
306+
"""
307+
# Simulate pythonw.exe on windows, which uses None for sys.__std*__
308+
monkeypatch.setattr(sys, "__stdout__", None)
309+
monkeypatch.setattr(sys, "__stderr__", None)
310+
311+
# Print to the various streams
312+
print("stdout")
313+
print("stdout: None", file=sys.__stdout__)
314+
print("stderr", file=sys.stderr)
315+
print("stderr: None", file=sys.__stderr__)
316+
317+
# Verify the prints were written to the expected stream
318+
captured = capsys.readouterr()
319+
# sys.stdout and None are sent to sys.stdout
320+
assert captured.out == "stdout\nstdout: None\nstderr: None\n"
321+
# Only sys.stderr gets written to sys.stderr
322+
assert captured.err == "stderr\n"
323+
324+
@pytest.mark.parametrize("pyw", (True, False))
325+
def test_print(self, monkeypatch, pyw):
326+
# Replace all 4 streams with tracking streams
327+
new_out, new_err, new_out_, new_err_ = self.patch(monkeypatch, pyw)
328+
329+
# Regular write/print calls go to stdout/stderr
330+
print("stdout")
331+
print("stderr", file=sys.stderr)
332+
# Use ShellPrint to write to `__std*__` and make sure it only returns
333+
# True if it should be writting to that stream.
334+
assert utils.ShellPrint().print("__stdout__") is not pyw
335+
assert utils.ShellPrint(error=True).print("__stderr__") is not pyw
336+
337+
# Verify the prints were written to the expected stream or discarded
338+
assert new_out.getvalue() == "stdout\n"
339+
assert new_err.getvalue() == "stderr\n"
340+
if not pyw:
341+
assert new_out_.getvalue() == "__stdout__\n"
342+
assert new_err_.getvalue() == "__stderr__\n"
343+
344+
@pytest.mark.parametrize("pyw", (True, False))
345+
def test_print_exc(self, monkeypatch, pyw):
346+
# Replace all 4 streams with tracking streams
347+
new_out, new_err, new_out_, new_err_ = self.patch(monkeypatch, pyw)
348+
349+
# Capture an exception and print it using ShellPrint
350+
try:
351+
raise RuntimeError("Test exception")
352+
except RuntimeError:
353+
check = traceback.format_exc()
354+
assert utils.ShellPrint(error=True).print_exc("A Test") is not pyw
355+
356+
# Verify that the text was only written to `sys.__stderr__` unless
357+
# that is None
358+
assert new_out.getvalue() == ""
359+
assert new_err.getvalue() == ""
360+
if not pyw:
361+
assert new_out_.getvalue() == ""
362+
assert new_err_.getvalue() == "\n".join(
363+
[
364+
" A Test ".center(79, "-"),
365+
check.rstrip(),
366+
" A Test ".center(79, "-"),
367+
"",
368+
]
369+
)

0 commit comments

Comments
 (0)