Skip to content

Commit 0c8c09f

Browse files
committed
Preserve Windows VT processing for serialized command output
1 parent e219606 commit 0c8c09f

4 files changed

Lines changed: 78 additions & 5 deletions

File tree

‎cmd2/command_toolbar.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -493,7 +493,9 @@ def _install_serializers(self) -> bool:
493493
with self._lock:
494494
self._serialized = True
495495
for stream in self._streams:
496-
stream.serializer = SerializedTerminalWriter(stream.original, reserved.lock, reserved.bridge)
496+
stream.serializer = SerializedTerminalWriter(
497+
stream.original, reserved.lock, reserved.bridge, output=reserved.display.terminal.output
498+
)
497499
# While the command display owns the terminal it has nothing of its own to draw, so its
498500
# renderer frames are suppressed: emitting one would reserve the usable height and
499501
# scroll command output off the screen. The toolbar is painted independently.

‎cmd2/managed_output.py‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,21 +29,26 @@
2929
if TYPE_CHECKING: # pragma: no cover
3030
from typing import TextIO
3131

32+
from prompt_toolkit.output import Output
33+
3234

3335
class SerializedTerminalWriter:
3436
"""A stream that writes to the terminal under the terminal transaction lock."""
3537

36-
def __init__(self, stream: "TextIO", lock: TerminalLock, bridge: Any = None) -> None:
38+
def __init__(self, stream: "TextIO", lock: TerminalLock, bridge: Any = None, *, output: "Output | None" = None) -> None:
3739
"""Wrap the terminal's own stream.
3840
3941
:param stream: the *original* terminal stream. Never a proxy over it: a physical
4042
writer that routed back into a proxy would queue its own output behind itself.
4143
:param lock: the terminal transaction lock every cmd2-controlled writer shares
4244
:param bridge: the renderer bridge to inform of managed output, if one is active
45+
:param output: the qualified physical backend, when writing to a reserved terminal.
46+
Its outer flush owns platform setup such as Windows VT console mode.
4347
"""
4448
self._stream = stream
4549
self._lock = lock
4650
self.bridge = bridge
51+
self._output = output
4752

4853
@property
4954
def stream(self) -> "TextIO":
@@ -58,11 +63,15 @@ def write(self, data: str) -> int:
5863
"""
5964
with self._lock.transaction("managed write"):
6065
try:
61-
written = self._stream.write(data)
66+
if self._output is None:
67+
written = self._stream.write(data)
68+
else:
69+
self._output.write_raw(data)
70+
written = len(data)
6271
# Flushed before the lock is given up. Left buffered, this output would reach
6372
# the terminal after whatever paints next, which is the ordering the
6473
# transaction is supposed to establish.
65-
self._stream.flush()
74+
self.flush()
6675
finally:
6776
# Recorded whether or not the write succeeded, and still inside the
6877
# transaction. The bridge drops any frame prepared against the cursor this
@@ -81,7 +90,10 @@ def flush(self) -> None:
8190
nothing either. The write that produced the buffered output already said so.
8291
"""
8392
with self._lock.transaction("managed flush"):
84-
self._stream.flush()
93+
if self._output is None:
94+
self._stream.flush()
95+
else:
96+
self._output.flush()
8597

8698
def __getattr__(self, name: str) -> Any:
8799
"""Delegate file attributes to the terminal stream.

‎tests/test_managed_output.py‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
from typing import Any, Self
1313

1414
import pytest
15+
from prompt_toolkit.data_structures import Size
16+
from prompt_toolkit.output import DummyOutput
17+
from prompt_toolkit.output.vt100 import Vt100_Output
1518

1619
from cmd2.command_toolbar import ToolbarStream
1720
from cmd2.managed_output import SerializedTerminalWriter
@@ -99,6 +102,59 @@ def test_file_attributes_come_from_the_stream(self) -> None:
99102
assert writer.readable() == stream.readable()
100103

101104

105+
class TestBackendWriting:
106+
@pytest.mark.parametrize("fail_flush", [False, True])
107+
def test_outer_backend_flush_owns_vt_mode_inside_the_transaction(self, fail_flush) -> None:
108+
"""Model Windows' outer backend on every platform: its inner VT writer alone does
109+
not enable console processing, and restoration must also happen on failure."""
110+
mode = 0
111+
observed = []
112+
113+
class Console(RecordingStream):
114+
def write(self, text: str) -> int:
115+
observed.append((mode, current_transaction()))
116+
return super().write(text)
117+
118+
def flush(self) -> None:
119+
super().flush()
120+
if fail_flush:
121+
raise RuntimeError("console flush failed")
122+
123+
console = Console()
124+
125+
class WindowsLikeOutput(DummyOutput):
126+
def __init__(self) -> None:
127+
self.vt100_output = Vt100_Output(console, lambda: Size(24, 80))
128+
129+
def write_raw(self, data: str) -> None:
130+
self.vt100_output.write_raw(data)
131+
132+
def flush(self) -> None:
133+
nonlocal mode
134+
original_mode = mode
135+
mode = 4
136+
try:
137+
self.vt100_output.flush()
138+
finally:
139+
mode = original_mode
140+
141+
bridge = RecordingBridge()
142+
writer = SerializedTerminalWriter(console, TerminalLock(), bridge, output=WindowsLikeOutput())
143+
data = "\x1b[31mred\x1b[0m\n"
144+
if fail_flush:
145+
with pytest.raises(RuntimeError, match="console flush failed"):
146+
writer.write(data)
147+
else:
148+
assert writer.write(data) == len(data)
149+
writer.flush()
150+
assert console.getvalue() == data
151+
assert mode == 0
152+
assert observed
153+
assert all(enabled == 4 and transaction is not None for enabled, transaction in observed)
154+
assert len(bridge.notes) == 1
155+
assert bridge.notes[0] is not None
156+
157+
102158
class TestInvalidationContract:
103159
def test_the_bridge_is_told_inside_the_emitting_transaction(self) -> None:
104160
"""Told afterwards, it would be told about a terminal that has changed again."""

‎tests/test_reserved_lifecycle.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,9 @@ def test_reserved_mode_serializes_output_instead_of_proxying_it(self) -> None:
211211
assert display._proxy is None
212212
assert display._streams
213213
assert all(stream.serializer is not None for stream in display._streams)
214+
# Commands must reach the qualified outer backend, whose flush enables VT
215+
# processing on Windows, rather than writing to its stream or inner writer.
216+
assert all(stream.serializer._output is harness.backend for stream in display._streams)
214217
finally:
215218
harness.close()
216219

0 commit comments

Comments
 (0)