|
29 | 29 | # import this module, many of these imports are lazy-loaded |
30 | 30 | # i.e. we only import the module when we use it. |
31 | 31 | import argparse |
| 32 | +import asyncio |
32 | 33 | import contextlib |
33 | 34 | import copy |
34 | 35 | import dataclasses |
|
71 | 72 | filters, |
72 | 73 | print_formatted_text, |
73 | 74 | ) |
74 | | -from prompt_toolkit.application import create_app_session, get_app |
| 75 | +from prompt_toolkit.application import Application, AppSession, create_app_session, get_app |
75 | 76 | from prompt_toolkit.auto_suggest import AutoSuggestFromHistory |
76 | 77 | from prompt_toolkit.completion import Completer, DummyCompleter |
77 | 78 | from prompt_toolkit.formatted_text import ANSI, AnyFormattedText |
|
82 | 83 | from prompt_toolkit.keys import Keys |
83 | 84 | from prompt_toolkit.layout import Window, walk |
84 | 85 | from prompt_toolkit.layout.containers import ConditionalContainer, FloatContainer |
85 | | -from prompt_toolkit.output import DummyOutput, create_output |
| 86 | +from prompt_toolkit.output import DummyOutput, Output, create_output |
86 | 87 | from prompt_toolkit.patch_stdout import patch_stdout |
87 | | -from prompt_toolkit.shortcuts import CompleteStyle, PromptSession, choice, set_title |
| 88 | +from prompt_toolkit.shortcuts import CompleteStyle, PromptSession, choice |
88 | 89 | from prompt_toolkit.styles import DynamicStyle |
89 | 90 | from rich.console import ( |
90 | 91 | Group, |
@@ -220,6 +221,11 @@ def __init__(self, msg: str = "") -> None: |
220 | 221 | from prompt_toolkit.buffer import Buffer |
221 | 222 |
|
222 | 223 |
|
| 224 | +# Errors raised when prompt-toolkit can't use a terminal. For example, GitHub Actions on Windows reports |
| 225 | +# isatty() as True but has no real console. |
| 226 | +_TERMINAL_INIT_ERRORS = (NoConsoleScreenBufferError, AttributeError, ValueError) |
| 227 | + |
| 228 | + |
223 | 229 | class _SavedCmd2Env: |
224 | 230 | """cmd2 environment settings that are backed up when entering an interactive Python shell.""" |
225 | 231 |
|
@@ -689,6 +695,26 @@ def __init__( |
689 | 695 | self._alert_thread: threading.Thread | None = None |
690 | 696 | self._alert_prompt_timestamp: float = 0.0 # Uses time.monotonic() |
691 | 697 |
|
| 698 | + # Members related to setting the window title. The lock is reentrant so a signal handler which sets the title |
| 699 | + # while the main thread is writing one doesn't deadlock. Such a nested call only records its title, and the |
| 700 | + # outer call writes it next. |
| 701 | + self._window_title_lock = threading.RLock() |
| 702 | + self._pending_window_title: str | None = None |
| 703 | + self._writing_window_title = False |
| 704 | + |
| 705 | + # The prompt loop on which a title callback is queued but hasn't run yet. Later titles set while it's queued |
| 706 | + # only replace the pending title, which that callback writes. |
| 707 | + self._window_title_callback_loop: asyncio.AbstractEventLoop | None = None |
| 708 | + |
| 709 | + # The stdout this app was created with. When the main session uses a DummyOutput, titles are written to it if |
| 710 | + # it's a terminal, including while a command's output is redirected. See _get_window_title_fallback_output(). |
| 711 | + self._window_title_stdout: TextIO = self.stdout |
| 712 | + self._window_title_fallback_checked = False |
| 713 | + self._window_title_fallback_output: Output | None = None |
| 714 | + |
| 715 | + # The AppSession of a select() menu while it is displayed, so window titles can find its event loop. |
| 716 | + self._select_app_session: AppSession | None = None |
| 717 | + |
692 | 718 | # Add functions decorated to be subcommands |
693 | 719 | self._register_subcommands(self) |
694 | 720 |
|
@@ -838,7 +864,7 @@ def _create_main_session( |
838 | 864 | kwargs["output"] = create_output(stdout=self.stdout) |
839 | 865 | return PromptSession(**kwargs) |
840 | 866 |
|
841 | | - except (NoConsoleScreenBufferError, AttributeError, ValueError): |
| 867 | + except _TERMINAL_INIT_ERRORS: |
842 | 868 | # Fallback to dummy input/output if PromptSession initialization fails. |
843 | 869 | # This can happen in some CI environments (like GitHub Actions on Windows) |
844 | 870 | # where isatty() is True but there is no real console. |
@@ -3659,14 +3685,19 @@ def _read_raw_input( |
3659 | 3685 | if not callable(prompt): |
3660 | 3686 | prompt = pt_filter_style(prompt) |
3661 | 3687 |
|
3662 | | - with patch_stdout(): |
3663 | | - try: |
3664 | | - # Set this session as the active one for UI/completion logic. |
3665 | | - self.active_session = session |
3666 | | - return session.prompt(prompt, **prompt_kwargs) |
3667 | | - finally: |
3668 | | - # Revert back to the main session. |
3669 | | - self.active_session = self.main_session |
| 3688 | + try: |
| 3689 | + with patch_stdout(): |
| 3690 | + try: |
| 3691 | + # Set this session as the active one for UI/completion logic. |
| 3692 | + self.active_session = session |
| 3693 | + return session.prompt(prompt, **prompt_kwargs) |
| 3694 | + finally: |
| 3695 | + # Revert back to the main session. |
| 3696 | + self.active_session = self.main_session |
| 3697 | + finally: |
| 3698 | + # Write any title whose callback the prompt's event loop didn't run. Do this after patch_stdout() |
| 3699 | + # exits so the write can't race with its writer thread on the same output. |
| 3700 | + self._flush_pending_window_title_quietly() |
3670 | 3701 |
|
3671 | 3702 | # We're not at a terminal, so we're likely reading from a file or a pipe. |
3672 | 3703 | prompt_obj = prompt if isinstance(prompt, (ANSI, str)) else prompt() |
@@ -4794,8 +4825,13 @@ def select(self, opts: str | Iterable[str] | Iterable[tuple[Any, str | None]], p |
4794 | 4825 | if self._is_tty_session(self.main_session): |
4795 | 4826 | try: |
4796 | 4827 | while True: |
4797 | | - with create_app_session(input=self.main_session.input, output=self.main_session.output): |
4798 | | - result = choice(message=prompt, options=fulloptions) |
| 4828 | + with create_app_session(input=self.main_session.input, output=self.main_session.output) as app_session: |
| 4829 | + self._select_app_session = app_session |
| 4830 | + try: |
| 4831 | + result = choice(message=prompt, options=fulloptions) |
| 4832 | + finally: |
| 4833 | + self._select_app_session = None |
| 4834 | + self._flush_pending_window_title_quietly() |
4799 | 4835 | if result is not None: |
4800 | 4836 | return result |
4801 | 4837 | except KeyboardInterrupt: |
@@ -5883,13 +5919,125 @@ def add_alert( |
5883 | 5919 | self._alert_queue.append(alert) |
5884 | 5920 | self._alert_condition.notify_all() |
5885 | 5921 |
|
5886 | | - @staticmethod |
5887 | | - def set_window_title(title: str) -> None: # pragma: no cover |
5888 | | - """Set the terminal window title. |
| 5922 | + def set_window_title(self, title: str) -> None: |
| 5923 | + """Set the title of this application's terminal. |
| 5924 | +
|
| 5925 | + The title is written to the output used by the main prompt, custom input prompts, and |
| 5926 | + select() menus. That is a dummy output when stdin or stdout isn't a terminal, or when the |
| 5927 | + terminal couldn't be set up. In that case, the title is written to the stdout this app was |
| 5928 | + created with instead, if that is a terminal. Otherwise, no title is written. |
| 5929 | +
|
| 5930 | + This can be called from any thread. If no prompt is running, then the title is written and |
| 5931 | + flushed before this returns, and output errors propagate to the caller. Such a direct write |
| 5932 | + from another thread isn't synchronized with other writers to the terminal, such as a prompt |
| 5933 | + which is just starting or finishing. |
| 5934 | +
|
| 5935 | + While a prompt is running, the title is written on the prompt's event loop, even when this is |
| 5936 | + called on that loop, so it doesn't interleave with rendering. It appears once the loop runs its |
| 5937 | + next callback, which can be delayed while the loop is busy, such as in a slow completer. If |
| 5938 | + several titles are set before then, only the latest is written. Output errors are ignored so |
| 5939 | + they can't disrupt the prompt. |
5889 | 5940 |
|
5890 | 5941 | :param title: the new window title |
5891 | 5942 | """ |
5892 | | - set_title(title) |
| 5943 | + with self._window_title_lock: |
| 5944 | + self._pending_window_title = title |
| 5945 | + loop = self._get_prompt_loop() |
| 5946 | + if loop is not None: |
| 5947 | + if self._window_title_callback_loop is loop: |
| 5948 | + # A callback queued on this loop hasn't run yet, and it will write this title. |
| 5949 | + return |
| 5950 | + |
| 5951 | + try: |
| 5952 | + loop.call_soon_threadsafe(self._flush_pending_window_title_quietly) |
| 5953 | + except RuntimeError: |
| 5954 | + # The loop closed after we found it, so the prompt is no longer rendering. |
| 5955 | + pass |
| 5956 | + else: |
| 5957 | + # If the prompt finishes before the loop runs the callback, then the prompt's |
| 5958 | + # cleanup writes the pending title. |
| 5959 | + self._window_title_callback_loop = loop |
| 5960 | + return |
| 5961 | + |
| 5962 | + # Keep ownership until the direct write finishes, so another caller or prompt cleanup cannot consume |
| 5963 | + # this request and receive or suppress its output error. |
| 5964 | + self._flush_pending_window_title() |
| 5965 | + |
| 5966 | + def _get_prompt_loop(self) -> asyncio.AbstractEventLoop | None: |
| 5967 | + """Return the event loop of the prompt-toolkit application drawing on this terminal, if one is running. |
| 5968 | +
|
| 5969 | + An application's loop is set for its whole run, including startup and teardown, when its is_running flag |
| 5970 | + is already cleared but the loop can still write to the terminal. |
| 5971 | + """ |
| 5972 | + apps: list[Application[Any]] = [self.active_session.app] |
| 5973 | + |
| 5974 | + # The main thread clears these when select() finishes, so read each one only once. |
| 5975 | + select_app_session = self._select_app_session |
| 5976 | + if select_app_session is not None: |
| 5977 | + select_app = select_app_session.app |
| 5978 | + if select_app is not None: |
| 5979 | + apps.append(select_app) |
| 5980 | + |
| 5981 | + for app in apps: |
| 5982 | + loop = app.loop |
| 5983 | + if loop is not None: |
| 5984 | + return loop |
| 5985 | + return None |
| 5986 | + |
| 5987 | + def _get_window_title_fallback_output(self) -> Output | None: |
| 5988 | + """Return an output for the stdout this app was created with, or None if that isn't a terminal. |
| 5989 | +
|
| 5990 | + Window titles use it when the main session has a DummyOutput. It's created the first time a title needs it. |
| 5991 | + This isn't a cached_property because inspect.getmembers() evaluates properties while this app is constructed. |
| 5992 | + """ |
| 5993 | + if not self._window_title_fallback_checked: |
| 5994 | + self._window_title_fallback_checked = True |
| 5995 | + |
| 5996 | + # A stream without isatty(), or a closed one, raises AttributeError or ValueError here. |
| 5997 | + with contextlib.suppress(*_TERMINAL_INIT_ERRORS): |
| 5998 | + if self._window_title_stdout.isatty(): |
| 5999 | + self._window_title_fallback_output = create_output(stdout=self._window_title_stdout) |
| 6000 | + |
| 6001 | + return self._window_title_fallback_output |
| 6002 | + |
| 6003 | + def _get_window_title_output(self) -> Output: |
| 6004 | + """Return the output which window titles are written to.""" |
| 6005 | + output = self.main_session.output |
| 6006 | + if isinstance(output, DummyOutput) and (fallback := self._get_window_title_fallback_output()) is not None: |
| 6007 | + return fallback |
| 6008 | + return output |
| 6009 | + |
| 6010 | + def _flush_pending_window_title(self) -> None: |
| 6011 | + """Write and flush the most recent title passed to set_window_title(), if it hasn't been written yet.""" |
| 6012 | + with self._window_title_lock: |
| 6013 | + if self._writing_window_title: |
| 6014 | + # This thread is already writing a title, such as when a signal handler calls set_window_title(). |
| 6015 | + # Writing now would nest inside that write, so leave the title pending for the outer call. |
| 6016 | + return |
| 6017 | + |
| 6018 | + self._writing_window_title = True |
| 6019 | + try: |
| 6020 | + # Keep going until no newer title arrived during the last write, so the latest title always wins. |
| 6021 | + while (title := self._pending_window_title) is not None: |
| 6022 | + self._pending_window_title = None |
| 6023 | + output = self._get_window_title_output() |
| 6024 | + output.set_title(title) |
| 6025 | + output.flush() |
| 6026 | + finally: |
| 6027 | + self._writing_window_title = False |
| 6028 | + |
| 6029 | + def _flush_pending_window_title_quietly(self) -> None: |
| 6030 | + """Flush a pending window title on a prompt's event loop or during its cleanup, ignoring output errors. |
| 6031 | +
|
| 6032 | + An error here would otherwise disrupt the prompt or replace its result. Writing to a closed stream raises |
| 6033 | + ValueError rather than OSError. |
| 6034 | + """ |
| 6035 | + with self._window_title_lock: |
| 6036 | + # Either this is the queued callback, or the prompt finished and its loop won't run that callback. |
| 6037 | + # Either way, the next title set during a prompt needs a new callback. |
| 6038 | + self._window_title_callback_loop = None |
| 6039 | + with contextlib.suppress(OSError, ValueError): |
| 6040 | + self._flush_pending_window_title() |
5893 | 6041 |
|
5894 | 6042 | def enable_command(self, command: str) -> None: |
5895 | 6043 | """Enable a command by restoring its functions. |
|
0 commit comments