Skip to content

Commit 238f532

Browse files
Refactoring of patch_stdout.
- Look at the AppSession in order to see which application is running, rather then looking at the event loop which is installed when `StdoutProxy` is created. This way, `patch_stdout` will work when prompt_toolkit applications with a different event loop run. - Fix printing when no application/event loop is running. - Use a background thread for flushing output to the application. The current code should be more performant on big outputs. - `PatchStdout` itself is now a context manager. It also requires closing when done (which will terminate the daemon thread). - Fixed the `raw` argument of `PatchStdout`.
1 parent 97d7e09 commit 238f532

File tree

5 files changed

+174
-49
lines changed

5 files changed

+174
-49
lines changed

prompt_toolkit/output/base.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
Interface for an output.
33
"""
44
from abc import ABCMeta, abstractmethod
5+
from typing import Optional, TextIO
56

67
from prompt_toolkit.data_structures import Size
78
from prompt_toolkit.styles import Attrs
@@ -24,6 +25,8 @@ class Output(metaclass=ABCMeta):
2425
:class:`~prompt_toolkit.output.win32.Win32Output`.
2526
"""
2627

28+
stdout: Optional[TextIO] = None
29+
2730
@abstractmethod
2831
def fileno(self) -> int:
2932
" Return the file descriptor to which we can write for the output. "

prompt_toolkit/output/defaults.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import sys
22
from typing import Optional, TextIO, cast
33

4-
from prompt_toolkit.patch_stdout import StdoutProxy
54
from prompt_toolkit.utils import (
65
get_term_environment_variable,
76
is_conemu_ansi,
@@ -49,6 +48,8 @@ def create_output(
4948
# If the patch_stdout context manager has been used, then sys.stdout is
5049
# replaced by this proxy. For prompt_toolkit applications, we want to use
5150
# the real stdout.
51+
from prompt_toolkit.patch_stdout import StdoutProxy
52+
5253
while isinstance(stdout, StdoutProxy):
5354
stdout = stdout.original_stdout
5455

prompt_toolkit/output/vt100.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -427,7 +427,7 @@ def __init__(
427427
assert hasattr(stdout, "encoding")
428428

429429
self._buffer: List[str] = []
430-
self.stdout = stdout
430+
self.stdout: TextIO = stdout
431431
self.write_binary = write_binary
432432
self.default_color_depth = default_color_depth
433433
self._get_size = get_size

prompt_toolkit/output/win32.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ def __init__(
9595
self.default_color_depth = default_color_depth
9696

9797
self._buffer: List[str] = []
98-
self.stdout = stdout
98+
self.stdout: TextIO = stdout
9999
self.hconsole = HANDLE(windll.kernel32.GetStdHandle(STD_OUTPUT_HANDLE))
100100

101101
self._in_alternate_screen = False

prompt_toolkit/patch_stdout.py

Lines changed: 167 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,16 @@
1717
Multiple applications can run in the body of the context manager, one after the
1818
other.
1919
"""
20+
import asyncio
21+
import queue
2022
import sys
2123
import threading
22-
from asyncio import get_event_loop
24+
import time
2325
from contextlib import contextmanager
24-
from typing import Generator, List, Optional, TextIO, cast
26+
from typing import Generator, List, Optional, TextIO, Union, cast
2527

26-
from .application import run_in_terminal
28+
from .application import get_app_session, run_in_terminal
29+
from .output import Output
2730

2831
__all__ = [
2932
"patch_stdout",
@@ -49,71 +52,175 @@ def patch_stdout(raw: bool = False) -> Generator[None, None, None]:
4952
:param raw: (`bool`) When True, vt100 terminal escape sequences are not
5053
removed/escaped.
5154
"""
52-
proxy = cast(TextIO, StdoutProxy(raw=raw))
55+
with StdoutProxy(raw=raw) as proxy:
56+
original_stdout = sys.stdout
57+
original_stderr = sys.stderr
5358

54-
original_stdout = sys.stdout
55-
original_stderr = sys.stderr
59+
# Enter.
60+
sys.stdout = cast(TextIO, proxy)
61+
sys.stderr = cast(TextIO, proxy)
5662

57-
# Enter.
58-
sys.stdout = proxy
59-
sys.stderr = proxy
63+
try:
64+
yield
65+
finally:
66+
sys.stdout = original_stdout
67+
sys.stderr = original_stderr
6068

61-
try:
62-
yield
63-
finally:
64-
# Exit.
65-
proxy.flush()
6669

67-
sys.stdout = original_stdout
68-
sys.stderr = original_stderr
70+
class _Done:
71+
" Sentinel value for stopping the stdout proxy. "
6972

7073

7174
class StdoutProxy:
7275
"""
73-
Proxy object for stdout which captures everything and prints output above
74-
the current application.
76+
File-like object, which prints everything written to it, output above the
77+
current application/prompt. This class is compatible with other file
78+
objects and can be used as a drop-in replacement for `sys.stdout` or can
79+
for instance be passed to `logging.StreamHandler`.
80+
81+
The current application, above which we print, is determined by looking
82+
what application currently runs in the `AppSession` that is active during
83+
the creation of this instance.
84+
85+
This class can be used as a context manager.
86+
87+
In order to avoid having to repaint the prompt continuously for every
88+
little write, a short delay of `sleep_between_writes` seconds will be added
89+
between writes in order to bundle many smaller writes in a short timespan.
7590
"""
7691

7792
def __init__(
78-
self, raw: bool = False, original_stdout: Optional[TextIO] = None
93+
self,
94+
sleep_between_writes: float = 0.2,
95+
raw: bool = False,
7996
) -> None:
8097

81-
original_stdout = original_stdout or sys.__stdout__
82-
83-
self.original_stdout = original_stdout
98+
self.sleep_between_writes = sleep_between_writes
99+
self.raw = raw
84100

85101
self._lock = threading.RLock()
86-
self._raw = raw
87102
self._buffer: List[str] = []
88103

89-
# errors/encoding attribute for compatibility with sys.__stdout__.
90-
self.errors = original_stdout.errors
91-
self.encoding = original_stdout.encoding
104+
# Keep track of the curret app session.
105+
self.app_session = get_app_session()
106+
107+
# See what output is active *right now*. We should do it at this point,
108+
# before this `StdoutProxy` instance is possibly assigned to `sys.stdout`.
109+
# Otherwise, if `patch_stdout` is used, and no `Output` instance has
110+
# been created, then the default output creation code will see this
111+
# proxy object as `sys.stdout`, and get in a recursive loop trying to
112+
# access `StdoutProxy.isatty()` which will again retrieve the output.
113+
self._output: Output = self.app_session.output
114+
115+
# Flush thread
116+
self._flush_queue: queue.Queue[Union[str, _Done]] = queue.Queue()
117+
self._flush_thread = self._start_write_thread()
118+
self.closed = False
119+
120+
def __enter__(self) -> "StdoutProxy":
121+
return self
92122

93-
self.loop = get_event_loop()
123+
def __exit__(self, *args) -> None:
124+
self.close()
94125

95-
def _write_and_flush(self, text: str) -> None:
126+
def close(self) -> None:
127+
"""
128+
Stop `StdoutProxy` proxy.
129+
130+
This will terminate the write thread, make sure everything is flushed
131+
and wait for the write thread to finish.
132+
"""
133+
if not self.closed:
134+
self._flush_queue.put(_Done())
135+
self._flush_thread.join()
136+
self.closed = True
137+
138+
def _start_write_thread(self) -> threading.Thread:
139+
thread = threading.Thread(
140+
target=self._write_thread,
141+
name="patch-stdout-flush-thread",
142+
daemon=True,
143+
)
144+
thread.start()
145+
return thread
146+
147+
def _write_thread(self) -> None:
148+
done = False
149+
150+
while not done:
151+
item = self._flush_queue.get()
152+
153+
if isinstance(item, _Done):
154+
break
155+
156+
# Don't bother calling when we got an empty string.
157+
if not item:
158+
continue
159+
160+
text = []
161+
text.append(item)
162+
163+
# Read the rest of the queue if more data was queued up.
164+
while True:
165+
try:
166+
item = self._flush_queue.get_nowait()
167+
except queue.Empty:
168+
break
169+
else:
170+
if isinstance(item, _Done):
171+
done = True
172+
else:
173+
text.append(item)
174+
175+
app_loop = self._get_app_loop()
176+
self._write_and_flush(app_loop, "".join(text))
177+
178+
# If an application was running that requires repainting, then wait
179+
# for a very short time, in order to bundle actual writes and avoid
180+
# having to repaint to often.
181+
if app_loop is not None:
182+
time.sleep(self.sleep_between_writes)
183+
184+
def _get_app_loop(self) -> Optional[asyncio.AbstractEventLoop]:
185+
"""
186+
Return the event loop for the application currently running in our
187+
`AppSession`.
188+
"""
189+
app = self.app_session.app
190+
191+
if app is None:
192+
return None
193+
194+
return app.loop
195+
196+
def _write_and_flush(
197+
self, loop: Optional[asyncio.AbstractEventLoop], text: str
198+
) -> None:
96199
"""
97200
Write the given text to stdout and flush.
98201
If an application is running, use `run_in_terminal`.
99202
"""
100-
if not text:
101-
# Don't bother calling `run_in_terminal` when there is nothing to
102-
# display.
103-
return
104203

105204
def write_and_flush() -> None:
106-
self.original_stdout.write(text)
107-
self.original_stdout.flush()
205+
if self.raw:
206+
self._output.write_raw(text)
207+
else:
208+
self._output.write(text)
209+
210+
self._output.flush()
108211

109212
def write_and_flush_in_loop() -> None:
110213
# If an application is running, use `run_in_terminal`, otherwise
111214
# call it directly.
112-
run_in_terminal.run_in_terminal(write_and_flush, in_executor=False)
215+
run_in_terminal(write_and_flush, in_executor=False)
113216

114-
# Make sure `write_and_flush` is executed *in* the event loop, not in
115-
# another thread.
116-
self.loop.call_soon_threadsafe(write_and_flush_in_loop)
217+
if loop is None:
218+
# No loop, write immediately.
219+
write_and_flush()
220+
else:
221+
# Make sure `write_and_flush` is executed *in* the event loop, not
222+
# in another thread.
223+
loop.call_soon_threadsafe(write_and_flush_in_loop)
117224

118225
def _write(self, data: str) -> None:
119226
"""
@@ -133,15 +240,15 @@ def _write(self, data: str) -> None:
133240
self._buffer = [after]
134241

135242
text = "".join(to_write)
136-
self._write_and_flush(text)
243+
self._flush_queue.put(text)
137244
else:
138245
# Otherwise, cache in buffer.
139246
self._buffer.append(data)
140247

141248
def _flush(self) -> None:
142249
text = "".join(self._buffer)
143250
self._buffer = []
144-
self._write_and_flush(text)
251+
self._flush_queue.put(text)
145252

146253
def write(self, data: str) -> int:
147254
with self._lock:
@@ -156,12 +263,26 @@ def flush(self) -> None:
156263
with self._lock:
157264
self._flush()
158265

266+
@property
267+
def original_stdout(self) -> TextIO:
268+
return self._output.stdout or sys.__stdout__
269+
270+
# Attributes for compatibility with sys.__stdout__:
271+
159272
def fileno(self) -> int:
160-
"""
161-
Return file descriptor.
162-
"""
163-
# This is important for code that expects sys.stdout.fileno() to work.
164-
return self.original_stdout.fileno()
273+
return self._output.fileno()
165274

166275
def isatty(self) -> bool:
167-
return self.original_stdout.isatty()
276+
stdout = self._output.stdout
277+
if stdout is None:
278+
return False
279+
280+
return stdout.isatty()
281+
282+
@property
283+
def encoding(self) -> str:
284+
return self._output.encoding()
285+
286+
@property
287+
def errors(self) -> str:
288+
return "strict"

0 commit comments

Comments
 (0)