1919from unittest import mock
2020
2121import 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
2323from prompt_toolkit .application .current import set_app
2424from prompt_toolkit .auto_suggest import AutoSuggestFromHistory
2525from prompt_toolkit .completion import DummyCompleter
@@ -4990,6 +4990,7 @@ def do_work(self, _statement):
49904990@pytest .mark .parametrize ("from_worker" , [False , True ])
49914991def 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
50425228def test_set_window_title_without_a_terminal () -> None :
0 commit comments