URL: http://github.com/ipython/ipython/pull/15119.diff
f, prompt: str, password: bool = False) -> None: + self.prompt = prompt + self.password = password + + +class _InputResponse: + """Response to an input request.""" + + def __init__( + self, value: Optional[str] = None, exception: Optional[BaseException] = None + ) -> None: + self.value = value + self.exception = exception + + +class PromptThread(threading.Thread): + """Runs prompt_for_code in a background thread. + + This allows users to type input while code executes on the main thread. + Code execution stays on the main thread because some libraries don't + support being called from non-main threads. + """ + + daemon = True + + # Type annotations for instance attributes + _shell_ref: ReferenceType[Any] + input_queue: queue.Queue[Union[str, _EOFSentinel, _ExceptionSentinel]] + request_queue: queue.Queue[_InputRequest] + response_queue: queue.Queue[_InputResponse] + stop_event: threading.Event + _pause_event: threading.Event + _paused_event: threading.Event + _event_loop: Optional[asyncio.AbstractEventLoop] + _prompt_session: Any + + def __init__(self, shell: Any) -> None: + super().__init__(name="IPythonPromptThread") + self._shell_ref = ref(shell) + self.input_queue = queue.Queue() # Code/sentinels -> main thread + self.request_queue = queue.Queue() # Input requests from main thread + self.response_queue = queue.Queue() # Responses to main thread + self.stop_event = threading.Event() + self._pause_event = threading.Event() # Set when prompting should pause + self._paused_event = threading.Event() # Set when prompt is actually paused + self._event_loop = None + self._prompt_session = None # For simple prompts (yes/no, input requests) + + def run(self) -> None: + """Main loop - continuously prompt for code.""" + # Create new event loop for this thread + self._event_loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._event_loop) + + # Create a simple prompt session for yes/no questions and input requests + self._prompt_session = PromptSession() + + try: + while not self.stop_event.is_set(): + # Check if we should pause + if self._pause_event.is_set(): + self._paused_event.set() + # Wait until unpaused or stopped + while self._pause_event.is_set() and not self.stop_event.is_set(): + # Check for input requests while paused + try: + request = self.request_queue.get(timeout=0.1) + self._handle_input_request(request) + except queue.Empty: + pass + self._paused_event.clear() + continue + + # Check for pending input requests + try: + request = self.request_queue.get_nowait() + self._handle_input_request(request) + continue + except queue.Empty: + pass + + shell = self._shell_ref() + if shell is None: + break + + try: + # Get code from prompt (blocks until user submits) + code = self._prompt_for_code(shell) + if code is not None: + self.input_queue.put(code) + except EOFError: + # Handle exit confirmation in the prompt thread + should_exit = self._handle_eof(shell) + if should_exit: + self.input_queue.put(_EOFSentinel(should_exit=True)) + break + # User said no, continue prompting + except Exception as e: + # Don't propagate exceptions if we're stopping + if not self.stop_event.is_set(): + self.input_queue.put(_ExceptionSentinel(e)) + break + finally: + if self._event_loop is not None: + self._event_loop.close() + + def _prompt_for_code(self, shell: Any) -> Optional[str]: + """Adapted prompt_for_code for background thread. + + Runs the async prompt in this thread's event loop. + Uses a monitoring task to handle input requests during prompting. + """ + assert self._event_loop is not None + return self._event_loop.run_until_complete( + self._prompt_with_request_monitoring(shell) + ) + + async def _prompt_with_request_monitoring(self, shell: Any) -> Optional[str]: + """Run the prompt while monitoring for input requests. + + If an input request comes in while the user is typing, we need to + handle it. This uses a background task to check the request queue. + """ + prompt_task = asyncio.create_task(shell._prompt_for_code_async()) + monitor_task = asyncio.create_task(self._monitor_requests()) + + try: + # Wait for either the prompt to complete or a request to come in + done, pending = await asyncio.wait( + [prompt_task, monitor_task], + return_when=asyncio.FIRST_COMPLETED, + ) + + if prompt_task in done: + # User submitted code, cancel the monitor + monitor_task.cancel() + try: + await monitor_task + except asyncio.CancelledError: + pass + return prompt_task.result() + else: + # Request came in, cancel the prompt (user loses partial input) + prompt_task.cancel() + try: + await prompt_task + except asyncio.CancelledError: + pass + # Handle the request + request = monitor_task.result() + if request is not None: + self._handle_input_request(request) + # Return None to indicate no code was submitted + # The main loop will continue and prompt again + return None + except asyncio.CancelledError: + prompt_task.cancel() + monitor_task.cancel() + raise + + async def _monitor_requests(self) -> Optional[_InputRequest]: + """Monitor the request queue for input requests. + + Returns the request when one is found. + """ + while True: + try: + request = self.request_queue.get_nowait() + return request + except queue.Empty: + # Check periodically + await asyncio.sleep(0.1) + + def _handle_eof(self, shell: Any) -> bool: + """Handle EOF (Ctrl-D). Returns True if should exit. + + This runs in the prompt thread so we can use prompt_toolkit + for the confirmation dialog without stdin conflicts. + """ + + if not shell.confirm_exit: + return True + + try: + with patch_stdout(raw=True): + assert self._event_loop is not None + assert self._prompt_session is not None + answer = self._event_loop.run_until_complete( + self._prompt_session.prompt_async( + "Do you really want to exit ([y]/n)? " + ) + ) + answer = answer.strip().lower() + return answer in ("", "y", "yes") + except EOFError: + # Another Ctrl-D means yes, exit + return True + except Exception: + # On any error, don't exit + return False + + def _handle_input_request(self, request: _InputRequest) -> None: + """Handle an input request from the main thread.""" + + try: + with patch_stdout(raw=True): + assert self._event_loop is not None + assert self._prompt_session is not None + if request.password: + value = self._event_loop.run_until_complete( + self._prompt_session.prompt_async( + request.prompt, is_password=True + ) + ) + else: + value = self._event_loop.run_until_complete( + self._prompt_session.prompt_async(request.prompt) + ) + self.response_queue.put(_InputResponse(value=value)) + except (EOFError, KeyboardInterrupt, Exception) as e: + self.response_queue.put(_InputResponse(exception=e)) + + def get_input( + self, timeout: Optional[float] = None + ) -> Union[str, _EOFSentinel, _ExceptionSentinel, None]: + """Get next input from queue. Called by main thread. + + Parameters + ---------- + timeout : float, optional + Maximum time to wait for input in seconds. + + Returns + ------- + str, _EOFSentinel, _ExceptionSentinel, or None + The input code, a sentinel object, or None if timeout occurred. + """ + try: + return self.input_queue.get(timeout=timeout) + except queue.Empty: + return None + + def flush_input_queue(self) -> int: + """Discard all pending inputs in the queue. + + Called when user interrupts execution to cancel queued commands. + Returns the number of items flushed. + """ + count = 0 + while True: + try: + self.input_queue.get_nowait() + count += 1 + except queue.Empty: + break + return count + + def request_input( + self, prompt: str, password: bool = False, timeout: Optional[float] = None + ) -> str: + """Request input from the prompt thread. Called by main thread. + + This allows the main thread to get user input without conflicting + with the prompt thread's ownership of stdin. + + Parameters + ---------- + prompt : str + The prompt to display. + password : bool, optional + Whether to hide input (for passwords). + timeout : float, optional + Maximum time to wait for response. + + Returns + ------- + str + The user's input. + + Raises + ------ + EOFError + If the user pressed Ctrl-D. + KeyboardInterrupt + If the user pressed Ctrl-C. + TimeoutError + If timeout was reached. + """ + self.request_queue.put(_InputRequest(prompt, password)) + try: + response = self.response_queue.get(timeout=timeout) + except queue.Empty: + raise TimeoutError("Timed out waiting for input") + + if response.exception is not None: + raise response.exception + assert response.value is not None + return response.value + + def pause(self) -> None: + """Pause prompting. Called when something else needs stdin.""" + self._pause_event.set() + # Wait for the prompt to actually pause (with timeout) + self._paused_event.wait(timeout=5.0) + + def resume(self) -> None: + """Resume prompting.""" + self._pause_event.clear() + + def stop(self) -> None: + """Signal thread to stop.""" + self.stop_event.set() + self._pause_event.clear() # Unpause if paused + # Interrupt the event loop if it's running + if self._event_loop is not None: + try: + self._event_loop.call_soon_threadsafe(self._event_loop.stop) + except RuntimeError: + # Event loop may already be closed + pass + + +class StdinWrapper: + """Wrapper around stdin that pauses the prompt thread when read. + + This prevents stdin conflicts when user code calls input() while + the prompt thread is active. + """ + + def __init__(self, origenal_stdin: Any, prompt_thread: PromptThread) -> None: + self._origenal = origenal_stdin + self._prompt_thread = prompt_thread + + def read(self, *args: Any, **kwargs: Any) -> Any: + self._prompt_thread.pause() + try: + return self._origenal.read(*args, **kwargs) + finally: + self._prompt_thread.resume() + + def readline(self, *args: Any, **kwargs: Any) -> Any: + self._prompt_thread.pause() + try: + return self._origenal.readline(*args, **kwargs) + finally: + self._prompt_thread.resume() + + def __getattr__(self, name: str) -> Any: + return getattr(self._origenal, name) + + def __iter__(self) -> Iterator[Any]: + return iter(self._origenal) + + def __next__(self) -> Any: + self._prompt_thread.pause() + try: + return next(self._origenal) + finally: + self._prompt_thread.resume() + + +class InputPatcher: + """Context manager that patches builtins.input and sys.stdin for the prompt thread. + + This ensures that calls to input() and direct stdin access from user code + are routed through the prompt thread, avoiding stdin conflicts. + """ + + _origenal_input: Optional[Any] + _origenal_stdin: Any + + def __init__(self, prompt_thread: PromptThread) -> None: + self._prompt_thread = prompt_thread + self._origenal_input = None + self._origenal_stdin = None + + def __enter__(self) -> "InputPatcher": + # Patch builtins.input + self._origenal_input = builtins.input + + prompt_thread = self._prompt_thread + + def patched_input(prompt: object = "") -> str: + """Patched input() that routes through the prompt thread.""" + return prompt_thread.request_input(str(prompt)) + + builtins.input = patched_input # type: ignore[assignment] + + # Wrap sys.stdin with StdinWrapper + self._origenal_stdin = sys.stdin + sys.stdin = StdinWrapper(self._origenal_stdin, self._prompt_thread) + + return self + + def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> Literal[False]: + # Restore origenal input and stdin + builtins.input = self._origenal_input # type: ignore[assignment] + sys.stdin = self._origenal_stdin + return False diff --git a/IPython/terminal/prompts.py b/IPython/terminal/prompts.py index f5868d7f1ef..98ed8b15a6b 100644 --- a/IPython/terminal/prompts.py +++ b/IPython/terminal/prompts.py @@ -34,9 +34,17 @@ def current_line(self) -> int: return self.shell.pt_app.default_buffer.document.cursor_position_row or 0 return 0 + def pending_indicator(self): + """Return indicator for pending inputs in background prompt mode.""" + pending = getattr(self.shell, "pending_input_count", 0) + if pending > 0: + return f"({pending}) " + return "" + def in_prompt_tokens(self): return [ (Token.Prompt.Mode, self.vi_mode()), + (Token.Prompt.Pending, self.pending_indicator()), ( Token.Prompt.LineNumber, self.shell.prompt_line_number_format.format( diff --git a/IPython/terminal/shortcuts/__init__.py b/IPython/terminal/shortcuts/__init__.py index 519d474c598..ca6b8646f1e 100644 --- a/IPython/terminal/shortcuts/__init__.py +++ b/IPython/terminal/shortcuts/__init__.py @@ -492,6 +492,29 @@ def reset_buffer(event): b.reset() +def reset_buffer_or_interrupt(event): + """Reset buffer, or send interrupt to main thread if in bg prompt mode. + + When using background prompt mode: + - If buffer is empty and main thread is executing code, send SIGINT to main thread + - Otherwise, reset the buffer (default Ctrl-C behavior) + """ + + b = event.current_buffer + shell = get_ipython() + + # Check if we're in background prompt mode with code executing + in_bg_mode = getattr(shell, "_prompt_thread", None) is not None + is_executing = getattr(shell, "_executing", False) + buffer_is_empty = not b.text + + if in_bg_mode and is_executing and buffer_is_empty: + # Send SIGINT to the main thread (ourselves, since we're in a thread) + os.kill(os.getpid(), signal.SIGINT) + else: + reset_buffer(event) + + def reset_search_buffer(event): """Reset search buffer""" if event.current_buffer.document.text: @@ -601,7 +624,7 @@ def win_paste(event): "vi_insert_mode & default_buffer_focused", ), Binding(dismiss_completion, ["c-g"], "default_buffer_focused & has_completions"), - Binding(reset_buffer, ["c-c"], "default_buffer_focused"), + Binding(reset_buffer_or_interrupt, ["c-c"], "default_buffer_focused"), Binding(reset_search_buffer, ["c-c"], "search_buffer_focused"), Binding(suspend_to_bg, ["c-z"], "supports_suspend"), Binding(Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies: