Skip to content

Commit 478035f

Browse files
committed
Replace terminal_lock with asyncio in async_alert
Replaced 'threading.RLock' 'terminal_lock' with 'asyncio.call_soon_threadsafe' and 'self._in_prompt' flag for 'async_alert'. Updated 'examples/async_printing.py' to remove lock usage. Updated tests to mock event loop and '_in_prompt' state.
1 parent 378bd01 commit 478035f

3 files changed

Lines changed: 153 additions & 150 deletions

File tree

cmd2/cmd2.py

Lines changed: 119 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -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

examples/async_printing.py

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -48,20 +48,13 @@ def __init__(self, *args, **kwargs) -> None:
4848

4949
def _preloop_hook(self) -> None:
5050
"""Start the alerter thread."""
51-
# This runs after cmdloop() acquires self.terminal_lock, which will be locked until the prompt appears.
52-
# Therefore this is the best place to start the alerter thread since there is no risk of it alerting
53-
# before the prompt is displayed. You can also start it via a command if its not something that should
54-
# be running during the entire application. See do_start_alerts().
5551
self._stop_event.clear()
5652

5753
self._alerter_thread = threading.Thread(name='alerter', target=self._alerter_thread_func)
5854
self._alerter_thread.start()
5955

6056
def _postloop_hook(self) -> None:
6157
"""Stops the alerter thread."""
62-
# After this function returns, cmdloop() releases self.terminal_lock which could make the alerter
63-
# thread think the prompt is on screen. Therefore this is the best place to stop the alerter thread.
64-
# You can also stop it via a command. See do_stop_alerts().
6558
self._stop_event.set()
6659
if self._alerter_thread.is_alive():
6760
self._alerter_thread.join()
@@ -160,9 +153,7 @@ def _alerter_thread_func(self) -> None:
160153
self._next_alert_time = 0
161154

162155
while not self._stop_event.is_set():
163-
# Always acquire terminal_lock before printing alerts or updating the prompt.
164-
# To keep the app responsive, do not block on this call.
165-
if self.terminal_lock.acquire(blocking=False):
156+
try:
166157
# Get any alerts that need to be printed
167158
alert_str = self._generate_alert_str()
168159

@@ -183,8 +174,8 @@ def _alerter_thread_func(self) -> None:
183174
elif self.need_prompt_refresh():
184175
self.async_refresh_prompt()
185176

186-
# Don't forget to release the lock
187-
self.terminal_lock.release()
177+
except RuntimeError:
178+
pass
188179

189180
self._stop_event.wait(0.5)
190181

0 commit comments

Comments
 (0)