Skip to content

Commit e49ec13

Browse files
Fix window title routing during select
1 parent 5028406 commit e49ec13

3 files changed

Lines changed: 217 additions & 10 deletions

File tree

‎cmd2/cmd2.py‎

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,8 @@
7171
filters,
7272
print_formatted_text,
7373
)
74-
from prompt_toolkit.application import create_app_session, get_app, get_app_or_none, get_app_session
74+
from prompt_toolkit.application import create_app_session, get_app, get_app_session
75+
from prompt_toolkit.application.current import AppSession
7576
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
7677
from prompt_toolkit.completion import Completer, DummyCompleter
7778
from prompt_toolkit.formatted_text import ANSI, AnyFormattedText
@@ -216,6 +217,11 @@ def __init__(self, msg: str = "") -> None:
216217
from prompt_toolkit.buffer import Buffer
217218

218219

220+
# select() creates a child session that ordinary background threads cannot see through contextvars.
221+
_select_app_sessions: dict[AppSession, list[AppSession]] = {}
222+
_select_app_sessions_lock = threading.Lock()
223+
224+
219225
class _SavedCmd2Env:
220226
"""cmd2 environment settings that are backed up when entering an interactive Python shell."""
221227

@@ -4788,10 +4794,20 @@ def select(self, opts: str | Iterable[str] | Iterable[tuple[Any, str | None]], p
47884794
fulloptions.append((opt[0], str(opt[0])))
47894795

47904796
if self._is_tty_session(self.main_session):
4797+
parent_session = get_app_session()
47914798
try:
47924799
while True:
4793-
with create_app_session(input=self.main_session.input, output=self.main_session.output):
4794-
result = choice(message=prompt, options=fulloptions)
4800+
with create_app_session(input=self.main_session.input, output=self.main_session.output) as session:
4801+
with _select_app_sessions_lock:
4802+
sessions = _select_app_sessions.setdefault(parent_session, [])
4803+
sessions.append(session)
4804+
try:
4805+
result = choice(message=prompt, options=fulloptions)
4806+
finally:
4807+
with _select_app_sessions_lock:
4808+
sessions.remove(session)
4809+
if not sessions:
4810+
del _select_app_sessions[parent_session]
47954811
if result is not None:
47964812
return result
47974813
except KeyboardInterrupt:
@@ -5867,18 +5883,22 @@ def set_window_title(title: str) -> None:
58675883
58685884
When a prompt-toolkit application is running, update the title on its event loop
58695885
to avoid writing to its output buffer from a background thread.
5870-
Calls use the current prompt-toolkit app session. Worker threads serving a custom
5871-
app session must inherit that session's context.
5886+
Calls use the current prompt-toolkit app session, including any active select() menu.
5887+
Worker threads serving a custom app session must inherit that session's context.
58725888
58735889
:param title: the new window title
58745890
"""
5875-
output = get_app_session().output
5891+
session = get_app_session()
5892+
with _select_app_sessions_lock:
5893+
while session in _select_app_sessions:
5894+
session = _select_app_sessions[session][-1]
5895+
app = session.app
5896+
output = app.output if app is not None else session.output
58765897

58775898
def set_title_and_flush() -> None:
58785899
output.set_title(title)
58795900
output.flush()
58805901

5881-
app = get_app_or_none()
58825902
loop = app.loop if app is not None else None
58835903
if loop is not None and loop.is_running():
58845904
try:

‎docs/features/prompt.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@ Alerts can interact with the CLI in two ways:
5656

5757
Title changes are flushed without waiting for the next prompt. When a prompt-toolkit application is
5858
running, the update is scheduled on its event loop so background threads can change the title
59-
without writing to the prompt's output buffer directly.
59+
without writing to the prompt's output buffer directly. The title is sent to that application's
60+
output, including while a `select()` menu is active.
6061

6162
Calls use the current prompt-toolkit app session. If you create a custom session, pass its context
6263
to worker threads, for example with `contextvars.copy_context().run`.

‎tests/test_cmd2.py‎

Lines changed: 188 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from unittest import mock
2020

2121
import pytest
22-
from prompt_toolkit.application import Application, create_app_session, get_app
22+
from prompt_toolkit.application import Application, create_app_session, get_app, get_app_session
2323
from prompt_toolkit.application.current import set_app
2424
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
2525
from prompt_toolkit.completion import DummyCompleter
@@ -4990,6 +4990,7 @@ def do_work(self, _statement):
49904990
@pytest.mark.parametrize("from_worker", [False, True])
49914991
def test_set_window_title_while_a_prompt_is_running(from_worker) -> None:
49924992
stream = io.StringIO()
4993+
session_stream = io.StringIO()
49934994
title_threads = []
49944995
observed = []
49954996
workers = []
@@ -5001,7 +5002,8 @@ def set_title(self, title):
50015002
super().set_title(title)
50025003

50035004
output = TitleOutput(stream, lambda: Size(rows=24, columns=80), enable_cpr=False)
5004-
with create_pipe_input() as pipe_input, create_app_session(input=pipe_input, output=output):
5005+
session_output = Vt100_Output(session_stream, lambda: Size(rows=24, columns=80), enable_cpr=False)
5006+
with create_pipe_input() as pipe_input, create_app_session(input=pipe_input, output=session_output):
50055007
app = Application(input=pipe_input, output=output)
50065008
sent = False
50075009

@@ -5037,6 +5039,190 @@ def after_render(_sender):
50375039

50385040
assert observed == [True]
50395041
assert title_threads == [loop_thread]
5042+
assert "\x1b]2;Working…\x07" not in session_stream.getvalue()
5043+
5044+
5045+
@pytest.mark.parametrize("interrupt", [False, True])
5046+
def test_set_window_title_from_a_worker_while_select_is_running(monkeypatch, interrupt) -> None:
5047+
stream = io.StringIO()
5048+
parent_stream = io.StringIO()
5049+
title_threads = []
5050+
worker_sessions = []
5051+
observed = []
5052+
rendered = threading.Event()
5053+
loop_thread = threading.get_ident()
5054+
parent = get_app_session()
5055+
run = Application.run
5056+
5057+
class TitleOutput(Vt100_Output):
5058+
def set_title(self, title):
5059+
title_threads.append(threading.get_ident())
5060+
super().set_title(title)
5061+
5062+
output = TitleOutput(stream, lambda: Size(rows=24, columns=80), enable_cpr=False)
5063+
parent_output = Vt100_Output(parent_stream, lambda: Size(rows=24, columns=80), enable_cpr=False)
5064+
5065+
def run_menu(menu, *args, **kwargs):
5066+
def check_title():
5067+
# Check before the menu's final render can flush a buffered title.
5068+
observed.append("\x1b]2;Working…\x07" in stream.getvalue())
5069+
if interrupt:
5070+
menu.exit(exception=KeyboardInterrupt)
5071+
else:
5072+
menu.exit(result="sweet")
5073+
5074+
def update_title():
5075+
worker_sessions.append(get_app_session())
5076+
if rendered.wait(timeout=5):
5077+
cmd2.Cmd.set_window_title("Working…")
5078+
menu.loop.call_soon_threadsafe(check_title)
5079+
5080+
# A plain worker sees the default session, not select's temporary one.
5081+
# An empty context keeps this true on Python builds that inherit context by default.
5082+
worker = threading.Thread(target=contextvars.Context().run, args=(update_title,), daemon=True)
5083+
menu.after_render += lambda _sender: rendered.set()
5084+
worker.start()
5085+
try:
5086+
return run(menu, *args, pre_run=lambda: menu.loop.call_later(5, lambda: menu.exit(result="timeout")), **kwargs)
5087+
finally:
5088+
worker.join(timeout=5)
5089+
assert not worker.is_alive()
5090+
5091+
with create_pipe_input() as pipe_input:
5092+
# Give the default session a safe output distinct from the menu's terminal.
5093+
monkeypatch.setattr(parent, "_input", pipe_input)
5094+
monkeypatch.setattr(parent, "_output", parent_output)
5095+
app = cmd2.Cmd(allow_cli_args=False, stdout=io.StringIO())
5096+
app.main_session = PromptSession(input=pipe_input, output=output)
5097+
monkeypatch.setattr(Application, "run", run_menu)
5098+
5099+
if interrupt:
5100+
with pytest.raises(KeyboardInterrupt):
5101+
app.select(["sweet", "salty"])
5102+
else:
5103+
assert app.select(["sweet", "salty"]) == "sweet"
5104+
5105+
assert observed == [True]
5106+
assert title_threads == [loop_thread]
5107+
assert worker_sessions == [parent]
5108+
assert "\x1b]2;Working…\x07" not in parent_stream.getvalue()
5109+
5110+
# Both normal return and interruption must restore the parent's routing.
5111+
cmd2.Cmd.set_window_title("Finished")
5112+
assert "\x1b]2;Finished\x07" in parent_stream.getvalue()
5113+
assert "\x1b]2;Finished\x07" not in stream.getvalue()
5114+
5115+
5116+
@pytest.mark.parametrize("same_parent", [False, True])
5117+
def test_set_window_title_during_nested_select_setup(monkeypatch, same_parent) -> None:
5118+
streams = [io.StringIO() for _ in range(4)]
5119+
outputs = [Vt100_Output(stream, lambda: Size(rows=24, columns=80), enable_cpr=False) for stream in streams]
5120+
parent = get_app_session()
5121+
calls = 0
5122+
5123+
def update_title(title):
5124+
contextvars.Context().run(cmd2.Cmd.set_window_title, title)
5125+
5126+
def choose(**_kwargs):
5127+
nonlocal calls
5128+
calls += 1
5129+
if calls == 1:
5130+
update_title("Outer before")
5131+
outer_session = app.main_session
5132+
app.main_session = PromptSession(input=pipe_input, output=outputs[2])
5133+
try:
5134+
if same_parent:
5135+
# Re-enter the original session, temporarily replacing its existing mapping.
5136+
contextvars.Context().run(app.select, ["inner"])
5137+
else:
5138+
app.select(["inner"])
5139+
finally:
5140+
app.main_session = outer_session
5141+
update_title("Outer after")
5142+
else:
5143+
# choice has not constructed an Application yet: use the innermost session output.
5144+
update_title("Inner")
5145+
with create_app_session(input=DummyInput(), output=outputs[3]):
5146+
cmd2.Cmd.set_window_title("Unrelated")
5147+
return "selected"
5148+
5149+
with create_pipe_input() as pipe_input:
5150+
monkeypatch.setattr(parent, "_input", pipe_input)
5151+
monkeypatch.setattr(parent, "_output", outputs[0])
5152+
app = cmd2.Cmd(allow_cli_args=False)
5153+
app.main_session = PromptSession(input=pipe_input, output=outputs[1])
5154+
monkeypatch.setattr("cmd2.cmd2.choice", choose)
5155+
assert app.select(["outer"]) == "selected"
5156+
update_title("Finished")
5157+
5158+
assert calls == 2
5159+
assert streams[0].getvalue() == "\x1b]2;Finished\x07"
5160+
assert streams[1].getvalue().endswith("\x1b]2;Outer before\x07\x1b]2;Outer after\x07")
5161+
assert streams[2].getvalue().endswith("\x1b]2;Inner\x07")
5162+
assert streams[3].getvalue() == "\x1b]2;Unrelated\x07"
5163+
5164+
5165+
def test_set_window_title_after_select_menus_finish_out_of_order(monkeypatch) -> None:
5166+
streams = [io.StringIO() for _ in range(3)]
5167+
outputs = [Vt100_Output(stream, lambda: Size(rows=24, columns=80), enable_cpr=False) for stream in streams]
5168+
entered = [threading.Event(), threading.Event()]
5169+
released = [threading.Event(), threading.Event()]
5170+
results = []
5171+
errors = []
5172+
5173+
def choose(*, options, **_kwargs):
5174+
index = options[0][0]
5175+
entered[index].set()
5176+
assert released[index].wait(timeout=5)
5177+
return index
5178+
5179+
def select(app, index):
5180+
try:
5181+
results.append(app.select([(index, str(index))]))
5182+
# Preserve worker failures for the main test thread instead of relying on pytest's warning hook.
5183+
except BaseException as ex: # noqa: BLE001
5184+
errors.append(ex)
5185+
5186+
with create_pipe_input() as pipe_input:
5187+
parent = get_app_session()
5188+
monkeypatch.setattr(parent, "_input", pipe_input)
5189+
monkeypatch.setattr(parent, "_output", outputs[0])
5190+
monkeypatch.setattr("cmd2.cmd2.choice", choose)
5191+
apps = [cmd2.Cmd(allow_cli_args=False) for _ in range(2)]
5192+
for index, app in enumerate(apps):
5193+
app.main_session = PromptSession(input=pipe_input, output=outputs[index + 1])
5194+
for output, stream in zip(outputs, streams, strict=True):
5195+
output.flush()
5196+
stream.seek(0)
5197+
stream.truncate()
5198+
workers = [
5199+
threading.Thread(target=contextvars.Context().run, args=(select, app, index), daemon=True)
5200+
for index, app in enumerate(apps)
5201+
]
5202+
try:
5203+
workers[0].start()
5204+
assert entered[0].wait(timeout=5)
5205+
workers[1].start()
5206+
assert entered[1].wait(timeout=5)
5207+
cmd2.Cmd.set_window_title("Both active")
5208+
released[0].set()
5209+
workers[0].join(timeout=5)
5210+
assert not workers[0].is_alive()
5211+
cmd2.Cmd.set_window_title("Second active")
5212+
finally:
5213+
for event in released:
5214+
event.set()
5215+
for worker in workers:
5216+
if worker.ident is not None:
5217+
worker.join(timeout=5)
5218+
assert not worker.is_alive()
5219+
cmd2.Cmd.set_window_title("Finished")
5220+
5221+
assert not errors
5222+
assert results == [0, 1]
5223+
assert streams[0].getvalue() == "\x1b]2;Finished\x07"
5224+
assert streams[1].getvalue() == ""
5225+
assert streams[2].getvalue() == "\x1b]2;Both active\x07\x1b]2;Second active\x07"
50405226

50415227

50425228
def test_set_window_title_without_a_terminal() -> None:

0 commit comments

Comments
 (0)