@@ -609,10 +609,9 @@ def _(event: Any) -> None: # pragma: no cover
609609 # This determines the value returned by cmdloop() when exiting the application
610610 self .exit_code = 0
611611
612- # This lock should be acquired before doing any asynchronous changes to the terminal to
613- # ensure the updates to the terminal don't interfere with the input being typed or output
614- # being printed by a command.
615- self .terminal_lock = threading .RLock ()
612+ # This flag is set to True when the prompt is displayed and the application is waiting for user input.
613+ # It is used by async_alert() to determine if it is safe to alert the user.
614+ self ._in_prompt = False
616615
617616 # Commands that have been disabled from use. This is to support commands that are only available
618617 # during specific states of the application. This dictionary's keys are the command names and its
@@ -3325,103 +3324,108 @@ def read_input(
33253324 :raises Exception: any exceptions raised by prompt()
33263325 """
33273326 self ._reset_completion_defaults ()
3328- if self .use_rawinput and self .stdin .isatty ():
3329- # Determine completer
3330- completer_to_use : Completer
3331- if completion_mode == utils .CompletionMode .NONE :
3332- completer_to_use = DummyCompleter ()
3333-
3334- # No up-arrow history when CompletionMode.NONE and history is None
3335- if history is None :
3336- history = []
3337- elif completion_mode == utils .CompletionMode .COMMANDS :
3338- completer_to_use = self .completer
3339- else :
3340- # Custom completion
3341- if parser is None :
3342- parser = argparse_custom .DEFAULT_ARGUMENT_PARSER (add_help = False )
3343- parser .add_argument (
3344- 'arg' ,
3345- suppress_tab_hint = True ,
3346- choices = choices ,
3347- choices_provider = choices_provider ,
3348- completer = completer ,
3349- )
3350- custom_settings = utils .CustomCompletionSettings (parser , preserve_quotes = preserve_quotes )
3351- completer_to_use = Cmd2Completer (self , custom_settings = custom_settings )
3352-
3353- # Use dynamic prompt if the prompt matches self.prompt
3354- def get_prompt () -> ANSI | str :
3355- return ANSI (self .prompt )
3356-
3357- prompt_to_use : Callable [[], ANSI | str ] | ANSI | str = ANSI (prompt )
3358- if prompt == self .prompt :
3359- prompt_to_use = get_prompt
3360-
3361- with patch_stdout ():
3362- if history is not None :
3363- # If custom history is provided, we use the prompt() shortcut
3364- # which can take a history object.
3365- history_to_use = InMemoryHistory ()
3366- for item in history :
3367- history_to_use .append_string (item )
3368-
3369- temp_session1 : PromptSession [str ] = PromptSession (
3370- history = history_to_use ,
3371- input = self .session .input ,
3372- output = self .session .output ,
3373- complete_style = self .session .complete_style ,
3374- complete_while_typing = self .session .complete_while_typing ,
3375- )
3327+ self ._in_prompt = True
3328+ try :
3329+ if self .use_rawinput and self .stdin .isatty ():
3330+ # Determine completer
3331+ completer_to_use : Completer
3332+ if completion_mode == utils .CompletionMode .NONE :
3333+ completer_to_use = DummyCompleter ()
3334+
3335+ # No up-arrow history when CompletionMode.NONE and history is None
3336+ if history is None :
3337+ history = []
3338+ elif completion_mode == utils .CompletionMode .COMMANDS :
3339+ completer_to_use = self .completer
3340+ else :
3341+ # Custom completion
3342+ if parser is None :
3343+ parser = argparse_custom .DEFAULT_ARGUMENT_PARSER (add_help = False )
3344+ parser .add_argument (
3345+ 'arg' ,
3346+ suppress_tab_hint = True ,
3347+ choices = choices ,
3348+ choices_provider = choices_provider ,
3349+ completer = completer ,
3350+ )
3351+ custom_settings = utils .CustomCompletionSettings (parser , preserve_quotes = preserve_quotes )
3352+ completer_to_use = Cmd2Completer (self , custom_settings = custom_settings )
3353+
3354+ # Use dynamic prompt if the prompt matches self.prompt
3355+ def get_prompt () -> ANSI | str :
3356+ return ANSI (self .prompt )
3357+
3358+ prompt_to_use : Callable [[], ANSI | str ] | ANSI | str = ANSI (prompt )
3359+ if prompt == self .prompt :
3360+ prompt_to_use = get_prompt
3361+
3362+ with patch_stdout ():
3363+ if history is not None :
3364+ # If custom history is provided, we use the prompt() shortcut
3365+ # which can take a history object.
3366+ history_to_use = InMemoryHistory ()
3367+ for item in history :
3368+ history_to_use .append_string (item )
3369+
3370+ temp_session1 : PromptSession [str ] = PromptSession (
3371+ history = history_to_use ,
3372+ input = self .session .input ,
3373+ output = self .session .output ,
3374+ complete_style = self .session .complete_style ,
3375+ complete_while_typing = self .session .complete_while_typing ,
3376+ )
3377+
3378+ return temp_session1 .prompt (
3379+ prompt_to_use ,
3380+ completer = completer_to_use ,
3381+ bottom_toolbar = self ._bottom_toolbar if self .include_bottom_toolbar else None ,
3382+ )
33763383
3377- return temp_session1 .prompt (
3384+ # history is None
3385+ return self .session .prompt (
33783386 prompt_to_use ,
33793387 completer = completer_to_use ,
33803388 bottom_toolbar = self ._bottom_toolbar if self .include_bottom_toolbar else None ,
33813389 )
33823390
3383- # history is None
3384- return self .session .prompt (
3385- prompt_to_use ,
3386- completer = completer_to_use ,
3391+ # Otherwise read from self.stdin
3392+ elif self .stdin .isatty ():
3393+ # on a tty, print the prompt first, then read the line
3394+ temp_session2 : PromptSession [str ] = PromptSession (
3395+ input = self .session .input ,
3396+ output = self .session .output ,
3397+ complete_style = self .session .complete_style ,
3398+ complete_while_typing = self .session .complete_while_typing ,
3399+ )
3400+ line = temp_session2 .prompt (
3401+ prompt ,
3402+ bottom_toolbar = self ._bottom_toolbar if self .include_bottom_toolbar else None ,
3403+ )
3404+ if len (line ) == 0 :
3405+ raise EOFError
3406+ return line .rstrip ('\n ' )
3407+ else :
3408+ # not a tty, just read the line
3409+ temp_session3 : PromptSession [str ] = PromptSession (
3410+ input = self .session .input ,
3411+ output = self .session .output ,
3412+ complete_style = self .session .complete_style ,
3413+ complete_while_typing = self .session .complete_while_typing ,
3414+ )
3415+ line = temp_session3 .prompt (
33873416 bottom_toolbar = self ._bottom_toolbar if self .include_bottom_toolbar else None ,
33883417 )
3418+ if len (line ) == 0 :
3419+ raise EOFError
3420+ line = line .rstrip ('\n ' )
33893421
3390- # Otherwise read from self.stdin
3391- elif self .stdin .isatty ():
3392- # on a tty, print the prompt first, then read the line
3393- temp_session2 : PromptSession [str ] = PromptSession (
3394- input = self .session .input ,
3395- output = self .session .output ,
3396- complete_style = self .session .complete_style ,
3397- complete_while_typing = self .session .complete_while_typing ,
3398- )
3399- line = temp_session2 .prompt (
3400- prompt ,
3401- bottom_toolbar = self ._bottom_toolbar if self .include_bottom_toolbar else None ,
3402- )
3403- if len (line ) == 0 :
3404- raise EOFError
3405- return line .rstrip ('\n ' )
3406- else :
3407- # not a tty, just read the line
3408- temp_session3 : PromptSession [str ] = PromptSession (
3409- input = self .session .input ,
3410- output = self .session .output ,
3411- complete_style = self .session .complete_style ,
3412- complete_while_typing = self .session .complete_while_typing ,
3413- )
3414- line = temp_session3 .prompt (
3415- bottom_toolbar = self ._bottom_toolbar if self .include_bottom_toolbar else None ,
3416- )
3417- if len (line ) == 0 :
3418- raise EOFError
3419- line = line .rstrip ('\n ' )
3422+ if self .echo :
3423+ self .poutput (f'{ prompt } { line } ' )
34203424
3421- if self .echo :
3422- self .poutput (f'{ prompt } { line } ' )
3425+ return line
34233426
3424- return line
3427+ finally :
3428+ self ._in_prompt = False
34253429
34263430 def _read_command_line (self , prompt : str ) -> str :
34273431 """Read command line from appropriate stdin.
@@ -3431,16 +3435,9 @@ def _read_command_line(self, prompt: str) -> str:
34313435 :raises Exception: whatever exceptions are raised by input() except for EOFError
34323436 """
34333437 try :
3434- # Wrap in try since terminal_lock may not be locked
3435- with contextlib .suppress (RuntimeError ):
3436- # Command line is about to be drawn. Allow asynchronous changes to the terminal.
3437- self .terminal_lock .release ()
34383438 return self .read_input (prompt , completion_mode = utils .CompletionMode .COMMANDS )
34393439 except EOFError :
34403440 return 'eof'
3441- finally :
3442- # Command line is gone. Do not allow asynchronous changes to the terminal.
3443- self .terminal_lock .acquire ()
34443441
34453442 def _cmdloop (self ) -> None :
34463443 """Repeatedly issue a prompt, accept input, parse it, and dispatch to apporpriate commands.
@@ -5457,9 +5454,8 @@ def async_alert(self, alert_msg: str, new_prompt: str | None = None) -> None:
54575454 To the user it appears as if an alert message is printed above the prompt and their
54585455 current input text and cursor location is left alone.
54595456
5460- This function needs to acquire self.terminal_lock to ensure a prompt is on screen.
5461- Therefore, it is best to acquire the lock before calling this function to avoid
5462- raising a RuntimeError.
5457+ This function checks self._in_prompt to ensure a prompt is on screen.
5458+ If the main thread is not at the prompt, a RuntimeError is raised.
54635459
54645460 This function is only needed when you need to print an alert or update the prompt while the
54655461 main thread is blocking at the prompt. Therefore, this should never be called from the main
@@ -5469,30 +5465,35 @@ def async_alert(self, alert_msg: str, new_prompt: str | None = None) -> None:
54695465 :param new_prompt: If you also want to change the prompt that is displayed, then include it here.
54705466 See async_update_prompt() docstring for guidance on updating a prompt.
54715467 :raises RuntimeError: if called from the main thread.
5472- :raises RuntimeError: if called while another thread holds `terminal_lock`
5468+ :raises RuntimeError: if main thread is not currently at the prompt.
54735469 """
54745470 if threading .current_thread () is threading .main_thread ():
54755471 raise RuntimeError ("async_alert should not be called from the main thread" )
54765472
5477- # Sanity check that can't fail if self.terminal_lock was acquired before calling this function
5478- if self .terminal_lock .acquire (blocking = False ):
5479- try :
5480- if new_prompt is not None :
5481- self .prompt = new_prompt
5473+ # Check if the main thread is currently waiting at the prompt
5474+ if not self ._in_prompt :
5475+ raise RuntimeError ("Main thread is not at the prompt" )
54825476
5483- if hasattr ( self , 'session' ) :
5484- # Invalidate to force prompt update if prompt changed
5485- self .session . app . invalidate ()
5477+ def _alert () -> None :
5478+ if new_prompt is not None :
5479+ self .prompt = new_prompt
54865480
5487- if alert_msg :
5488- sys .stdout .write (alert_msg + '\n ' )
5489- sys .stdout .flush ()
5481+ if alert_msg :
5482+ # Since we are running in the loop, patch_stdout context manager from read_input
5483+ # should be active (if tty), or at least we are in the main thread.
5484+ print (alert_msg )
54905485
5491- finally :
5492- self .terminal_lock .release ()
5486+ if hasattr (self , 'session' ):
5487+ # Invalidate to force prompt update
5488+ self .session .app .invalidate ()
54935489
5494- else :
5495- raise RuntimeError ("another thread holds terminal_lock" )
5490+ # Schedule the alert to run on the main thread's event loop
5491+ try :
5492+ self .session .app .loop .call_soon_threadsafe (_alert ) # type: ignore[union-attr]
5493+ except AttributeError :
5494+ # Fallback if loop is not accessible (e.g. prompt not running or session not initialized)
5495+ # This shouldn't happen if _in_prompt is True, unless prompt exited concurrently.
5496+ raise RuntimeError ("Event loop not available" ) from None
54965497
54975498 def async_update_prompt (self , new_prompt : str ) -> None : # pragma: no cover
54985499 """Update the command line prompt while the user is still typing at it.
@@ -5509,7 +5510,7 @@ def async_update_prompt(self, new_prompt: str) -> None: # pragma: no cover
55095510
55105511 :param new_prompt: what to change the prompt to
55115512 :raises RuntimeError: if called from the main thread.
5512- :raises RuntimeError: if called while another thread holds `terminal_lock`
5513+ :raises RuntimeError: if main thread is not currently at the prompt.
55135514 """
55145515 self .async_alert ('' , new_prompt )
55155516
@@ -5526,7 +5527,7 @@ def async_refresh_prompt(self) -> None: # pragma: no cover
55265527 thread to know when a refresh is needed.
55275528
55285529 :raises RuntimeError: if called from the main thread.
5529- :raises RuntimeError: if called while another thread holds `terminal_lock`
5530+ :raises RuntimeError: if main thread is not currently at the prompt.
55305531 """
55315532 self .async_alert ('' )
55325533
@@ -5679,9 +5680,6 @@ def cmdloop(self, intro: RenderableType = '') -> int:
56795680 original_sigterm_handler = signal .getsignal (signal .SIGTERM )
56805681 signal .signal (signal .SIGTERM , self .termination_signal_handler )
56815682
5682- # Grab terminal lock before the command line prompt has been drawn by prompt-toolkit
5683- self .terminal_lock .acquire ()
5684-
56855683 # Always run the preloop first
56865684 for func in self ._preloop_hooks :
56875685 func ()
@@ -5707,10 +5705,6 @@ def cmdloop(self, intro: RenderableType = '') -> int:
57075705 func ()
57085706 self .postloop ()
57095707
5710- # Release terminal lock now that postloop code should have stopped any terminal updater threads
5711- # This will also zero the lock count in case cmdloop() is called again
5712- self .terminal_lock .release ()
5713-
57145708 # Restore original signal handlers
57155709 signal .signal (signal .SIGINT , original_sigint_handler )
57165710
0 commit comments