diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 0368ba7b..d53bfcc1 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -10,30 +10,32 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.6, 3.7, 3.8, 3.9] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] steps: - - uses: actions/checkout@v2 - - name: Setup Python ${{ matrix.python-version }} - uses: actions/setup-python@v1 + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 with: python-version: ${{ matrix.python-version }} - - name: Install Dependencies + - name: Type Checking run: | - sudo apt remove python3-pip - python -m pip install --upgrade pip - python -m pip install . black isort mypy pytest readme_renderer - python -m pip install . types-dataclasses # Needed for Python 3.6 - pip list - - name: Type Checker + uvx --with . mypy src/ptpython/ + uvx --with . mypy examples/ + - name: Code formatting + if: ${{ matrix.python-version == '3.13' }} run: | - mypy ptpython - isort -c --profile black ptpython examples setup.py - black --check ptpython examples setup.py - - name: Run Tests + uvx ruff check . + uvx ruff format --check . + - name: Typos + if: ${{ matrix.python-version == '3.13' }} run: | - ./tests/run_tests.py + uvx typos . + - name: Unit test + run: | + uvx --with . pytest tests/ - name: Validate README.md + if: ${{ matrix.python-version == '3.13' }} # Ensure that the README renders correctly (required for uploading to PyPI). run: | + uv pip install readme_renderer python -m readme_renderer README.rst > /dev/null diff --git a/CHANGELOG b/CHANGELOG index 69a95e7d..838303f6 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,131 @@ CHANGELOG ========= +3.0.32: 2025-11-20 +------------------ + +Fixes: +- Use ANSI colors (instead of RGB colors) for the default code theme. This + improves the chances of having a better contrast with the background color, + because we now use what is configured in the terminal emulator. The theme is + called 'default-ansi' and exists alongside 'default'. + + +3.0.31: 2025-08-27 +------------------ + +Fixes: +- Add `patch_stdout_raw` parameter to `embed()`. +- Fix repl.min_brightness and repl.max_brightness config in history. +- Fix positioning of exit confirmation (compatibility with latest prompt_toolkit). + + +3.0.30: 2025-04-15 +------------------ + +New features: +- Show exception cause/context when printing chained exceptions. +- Reworked project layout and use pyproject.toml instead of setup.py. + +Breaking changes: +- Drop Python 3.7 support. + + +3.0.29: 2024-07-22 +------------------ + +Fixes: +- Further improve performance of dictionary completions. + + +3.0.28: 2024-07-22 +------------------ + +New features: +- Custom 'exit' function to return from REPL that + * doesn't terminate `sys.stdin` when `exit` is called (important for + `embed()`). + * doesn't require to be called with parentheses. + +Fixes: +- Clean up signatures on control-c. + + +3.0.27: 2024-05-27 +------------------ + +- Limit number of completions to 5k (for performance). +- Several typing fixes. + + +3.0.26: 2024-02-06 +------------------ + +Fixes: +- Handle `GeneratorExit` exception when leaving the paginator. + + +3.0.25: 2023-12-14 +------------------ + +Fixes: +- Fix handling of 'config file does not exist' when embedding ptpython. + + +3.0.24: 2023-12-13 +------------------ + +Fixes: +- Don't show "Impossible to read config file" warnings when no config file was + passed to `run_config()`. +- IPython integration fixes: + * Fix top-level await in IPython. + * Fix IPython `DeprecationWarning`. +- Output printing fixes: + * Paginate exceptions if pagination is enabled. + * Handle big outputs without running out of memory. +- Asyncio REPL improvements: + * From now on, passing `--asyncio` is required to activate the asyncio-REPL. + This will ensure that an event loop is created at the start in which we can + run top-level await statements. + * Use `get_running_loop()` instead of `get_event_loop()`. + * Better handling of `SystemExit` and control-c in the async REPL. + + +3.0.23: 2023-02-22 +------------------ + +Fixes: +- Don't print exception messages twice for unhandled exceptions. +- Added cursor shape support. + +Breaking changes: +- Drop Python 3.6 support. + + +3.0.22: 2022-12-06 +------------------ + +New features: +- Improve rendering performance when there are many completions. + + +3.0.21: 2022-11-25 +------------------ + +New features: +- Make ptipython respect more config changes. + (See: https://github.com/prompt-toolkit/ptpython/pull/110 ) +- Improved performance of `DictionaryCompleter` for slow mappings. + +Fixes: +- Call `super()` in `PythonInputFilter`. This will prevent potentially breakage + with an upcoming prompt_toolkit change. + (See: https://github.com/prompt-toolkit/python-prompt-toolkit/pull/1690 ) +- Improved type annotations. +- Added `py.typed` to the `package_data`. + + 3.0.20: 2021-09-14 ------------------ @@ -157,7 +282,7 @@ New features: - Optional pager for displaying outputs that don't fit on the screen. - Added --light-bg and --dark-bg flags to automatically optimize the brightness of the colors according to the terminal background. -- Addd `PTPYTHON_CONFIG_HOME` for explicitely setting the config directory. +- Add `PTPYTHON_CONFIG_HOME` for explicitly setting the config directory. - Show completion suffixes (like '(' for functions). Fixes: diff --git a/LICENSE b/LICENSE index 910b80a7..89a51144 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2015, Jonathan Slenders +Copyright (c) 2015-2023, Jonathan Slenders All rights reserved. Redistribution and use in source and binary forms, with or without modification, diff --git a/README.rst b/README.rst index 15464ba4..2559a3c4 100644 --- a/README.rst +++ b/README.rst @@ -9,10 +9,10 @@ ptpython pip install ptpython -.. image :: https://github.com/jonathanslenders/ptpython/raw/master/docs/images/example1.png +.. image :: ./docs/images/example1.png Ptpython is an advanced Python REPL. It should work on all -Python versions from 2.6 up to 3.9 and work cross platform (Linux, +Python versions from 2.6 up to 3.11 and work cross platform (Linux, BSD, OS X and Windows). Note: this version of ptpython requires at least Python 3.6. Install ptpython @@ -71,6 +71,7 @@ The help menu shows basic command-line options. -h, --help show this help message and exit --vi Enable Vi key bindings -i, --interactive Start interactive shell after executing this file. + --asyncio Run an asyncio event loop to support top-level "await". --light-bg Run on a light background (use dark colors for text). --dark-bg Run on a dark background (use light colors for text). --config-file CONFIG_FILE @@ -108,15 +109,15 @@ More screenshots The configuration menu: -.. image :: https://github.com/jonathanslenders/ptpython/raw/master/docs/images/ptpython-menu.png +.. image :: ./docs/images/ptpython-menu.png The history page and its help: -.. image :: https://github.com/jonathanslenders/ptpython/raw/master/docs/images/ptpython-history-help.png +.. image :: ./docs/images/ptpython-history-help.png Autocompletion: -.. image :: https://github.com/jonathanslenders/ptpython/raw/master/docs/images/file-completion.png +.. image :: ./docs/images/file-completion.png Embedding the REPL @@ -158,7 +159,7 @@ terminal, you have to check the "Use option as meta key" checkbox in your terminal settings. For iTerm2, you have to check "Left option acts as +Esc" in the options.) -.. image :: https://github.com/jonathanslenders/ptpython/raw/master/docs/images/multiline.png +.. image :: ./docs/images/multiline.png Syntax validation @@ -168,7 +169,21 @@ Before execution, ``ptpython`` will see whether the input is syntactically correct Python code. If not, it will show a warning, and move the cursor to the error. -.. image :: https://github.com/jonathanslenders/ptpython/raw/master/docs/images/validation.png +.. image :: ./docs/images/validation.png + + +Asyncio REPL and top level await +******************************** + +In order to get top-level ``await`` support, start ptpython as follows: + +.. code:: + + ptpython --asyncio + +This will spawn an asyncio event loop and embed the async REPL in the event +loop. After this, top-level await will work and statements like ``await +asyncio.sleep(10)`` will execute. Additional features @@ -193,7 +208,7 @@ variable, if set, can also be used to explicitly override where configuration is looked for. Have a look at this example to see what is possible: -`config.py `_ +`config.py `_ Note config file support currently only works when invoking `ptpython` directly. That it, the config file will be ignored when embedding ptpython in an application. @@ -207,13 +222,13 @@ with all the power that IPython has to offer, like magic functions and shell integration. Make sure that IPython has been installed. (``pip install ipython``) -.. image :: https://github.com/jonathanslenders/ptpython/raw/master/docs/images/ipython.png +.. image :: ./docs/images/ipython.png This is also available for embedding: .. code:: python - from ptpython.ipython.repl import embed + from ptpython.ipython import embed embed(globals(), locals()) @@ -238,8 +253,24 @@ Windows support ``prompt_toolkit`` and ``ptpython`` works better on Linux and OS X than on Windows. Some things might not work, but it is usable: -.. image :: https://github.com/jonathanslenders/ptpython/raw/master/docs/images/windows.png +.. image :: ./docs/images/windows.png + +Windows terminal integration +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If you are using the `Windows Terminal `_ and want to +integrate ``ptpython`` as a profile, go to *Settings -> Open JSON file* and add the +following profile under *profiles.list*: + +.. code-block:: JSON + { + "commandline": "%SystemRoot%\\System32\\cmd.exe /k ptpython", + "guid": "{f91d49a3-741b-409c-8a15-c4360649121f}", + "hidden": false, + "icon": "https://upload.wikimedia.org/wikipedia/commons/e/e6/Python_Windows_interpreter_icon_2006%E2%80%932016_Tiny.png", + "name": "ptpython@cmd" + } FAQ *** @@ -273,12 +304,12 @@ Special thanks to - `wcwidth `_: Determine columns needed for a wide characters. - `prompt_toolkit `_ for the interface. -.. |Build Status| image:: https://api.travis-ci.org/prompt-toolkit/ptpython.svg?branch=master - :target: https://travis-ci.org/prompt-toolkit/ptpython# +.. |Build Status| image:: https://github.com/prompt-toolkit/ptpython/actions/workflows/test.yaml/badge.svg + :target: https://github.com/prompt-toolkit/ptpython/actions/workflows/test.yaml .. |License| image:: https://img.shields.io/github/license/prompt-toolkit/ptpython.svg :target: https://github.com/prompt-toolkit/ptpython/blob/master/LICENSE -.. |PyPI| image:: https://pypip.in/version/ptpython/badge.svg - :target: https://pypi.python.org/pypi/ptpython/ +.. |PyPI| image:: https://img.shields.io/pypi/v/ptpython.svg + :target: https://pypi.org/project/ptpython/ :alt: Latest Version diff --git a/docs/concurrency-challenges.rst b/docs/concurrency-challenges.rst index b56d9698..0ff9c6c3 100644 --- a/docs/concurrency-challenges.rst +++ b/docs/concurrency-challenges.rst @@ -67,7 +67,7 @@ When a normal blocking embed is used: When an awaitable embed is used, for embedding in a coroutine, but having the event loop continue: * We run the input method from the blocking embed in an asyncio executor - and do an `await loop.run_in_excecutor(...)`. + and do an `await loop.run_in_executor(...)`. * The "eval" happens again in the main thread. * "print" is also similar, except that the pager code (if used) runs in an executor too. diff --git a/examples/asyncio-python-embed.py b/examples/asyncio-python-embed.py index 05f52f1d..cb909731 100755 --- a/examples/asyncio-python-embed.py +++ b/examples/asyncio-python-embed.py @@ -11,6 +11,7 @@ to stdout, it won't break the input line, but instead writes nicely above the prompt. """ + import asyncio from ptpython.repl import embed @@ -19,17 +20,17 @@ counter = [0] -async def print_counter(): +async def print_counter() -> None: """ Coroutine that prints counters and saves it in a global variable. """ while True: - print("Counter: %i" % counter[0]) + print(f"Counter: {counter[0]}") counter[0] += 1 await asyncio.sleep(3) -async def interactive_shell(): +async def interactive_shell() -> None: """ Coroutine that starts a Python REPL from which we can access the global counter variable. @@ -44,13 +45,10 @@ async def interactive_shell(): loop.stop() -def main(): - asyncio.ensure_future(print_counter()) - asyncio.ensure_future(interactive_shell()) - - loop.run_forever() - loop.close() +async def main() -> None: + asyncio.create_task(print_counter()) + await interactive_shell() if __name__ == "__main__": - main() + asyncio.run(main()) diff --git a/examples/asyncio-ssh-python-embed.py b/examples/asyncio-ssh-python-embed.py index 86b56073..bf79df78 100755 --- a/examples/asyncio-ssh-python-embed.py +++ b/examples/asyncio-ssh-python-embed.py @@ -5,6 +5,7 @@ Run this example and then SSH to localhost, port 8222. """ + import asyncio import logging @@ -32,31 +33,25 @@ def session_requested(self): return ReplSSHServerSession(self.get_namespace) -def main(port=8222): +async def main(port: int = 8222) -> None: """ Example that starts the REPL through an SSH server. """ - loop = asyncio.get_event_loop() - # Namespace exposed in the REPL. environ = {"hello": "world"} # Start SSH server. - def create_server(): + def create_server() -> MySSHServer: return MySSHServer(lambda: environ) - print("Listening on :%i" % port) - print('To connect, do "ssh localhost -p %i"' % port) + print(f"Listening on: {port}") + print(f'To connect, do "ssh localhost -p {port}"') - loop.run_until_complete( - asyncssh.create_server( - create_server, "", port, server_host_keys=["/etc/ssh/ssh_host_dsa_key"] - ) + await asyncssh.create_server( + create_server, "", port, server_host_keys=["/etc/ssh/ssh_host_dsa_key"] ) - - # Run eventloop. - loop.run_forever() + await asyncio.Future() # Wait forever. if __name__ == "__main__": - main() + asyncio.run(main()) diff --git a/examples/ptpython_config/config.py b/examples/ptpython_config/config.py index bf9d05fe..fe8a9ae8 100644 --- a/examples/ptpython_config/config.py +++ b/examples/ptpython_config/config.py @@ -1,9 +1,12 @@ -""" +r""" Configuration example for ``ptpython``. Copy this file to $XDG_CONFIG_HOME/ptpython/config.py On Linux, this is: ~/.config/ptpython/config.py +On macOS, this is: ~/Library/Application Support/ptpython/config.py +On Windows, this is: ~\AppData\Local\prompt_toolkit\ptpython\config.py """ + from prompt_toolkit.filters import ViInsertMode from prompt_toolkit.key_binding.key_processor import KeyPress from prompt_toolkit.keys import Keys @@ -49,7 +52,7 @@ def configure(repl): # Swap light/dark colors on or off repl.swap_light_and_dark = False - # Highlight matching parethesis. + # Highlight matching parentheses. repl.highlight_matching_parenthesis = True # Line wrapping. (Instead of horizontal scrolling.) @@ -69,6 +72,9 @@ def configure(repl): # Vi mode. repl.vi_mode = False + # Enable the modal cursor (when using Vi mode). Other options are 'Block', 'Underline', 'Beam', 'Blink under', 'Blink block', and 'Blink beam' + repl.cursor_shape_config = "Modal (vi)" + # Paste mode. (When True, don't insert whitespace after new line.) repl.paste_mode = False diff --git a/examples/python-embed-with-custom-prompt.py b/examples/python-embed-with-custom-prompt.py index 968aedc5..5e8c7079 100755 --- a/examples/python-embed-with-custom-prompt.py +++ b/examples/python-embed-with-custom-prompt.py @@ -2,26 +2,27 @@ """ Example of embedding a Python REPL, and setting a custom prompt. """ -from prompt_toolkit.formatted_text import HTML + +from prompt_toolkit.formatted_text import HTML, AnyFormattedText from ptpython.prompt_style import PromptStyle from ptpython.repl import embed -def configure(repl): +def configure(repl) -> None: # Probably, the best is to add a new PromptStyle to `all_prompt_styles` and # activate it. This way, the other styles are still selectable from the # menu. class CustomPrompt(PromptStyle): - def in_prompt(self): + def in_prompt(self) -> AnyFormattedText: return HTML("Input[%s]: ") % ( repl.current_statement_index, ) - def in2_prompt(self, width): + def in2_prompt(self, width: int) -> AnyFormattedText: return "...: ".rjust(width) - def out_prompt(self): + def out_prompt(self) -> AnyFormattedText: return HTML("Result[%s]: ") % ( repl.current_statement_index, ) @@ -30,7 +31,7 @@ def out_prompt(self): repl.prompt_style = "custom" -def main(): +def main() -> None: embed(globals(), locals(), configure=configure) diff --git a/examples/python-embed.py b/examples/python-embed.py index ac2cd06f..a7481011 100755 --- a/examples/python-embed.py +++ b/examples/python-embed.py @@ -1,10 +1,10 @@ #!/usr/bin/env python -""" -""" +""" """ + from ptpython.repl import embed -def main(): +def main() -> None: embed(globals(), locals(), vi_mode=False) diff --git a/examples/python-input.py b/examples/python-input.py index 567c2ee6..d586d0f5 100755 --- a/examples/python-input.py +++ b/examples/python-input.py @@ -1,6 +1,6 @@ #!/usr/bin/env python -""" -""" +""" """ + from ptpython.python_input import PythonInput diff --git a/examples/ssh-and-telnet-embed.py b/examples/ssh-and-telnet-embed.py index 378784ce..2b293e6f 100755 --- a/examples/ssh-and-telnet-embed.py +++ b/examples/ssh-and-telnet-embed.py @@ -6,18 +6,23 @@ https://gist.github.com/vxgmichel/7685685b3e5ead04ada4a3ba75a48eef """ +from __future__ import annotations + import asyncio import pathlib import asyncssh from prompt_toolkit import print_formatted_text -from prompt_toolkit.contrib.ssh.server import PromptToolkitSSHServer -from prompt_toolkit.contrib.telnet.server import TelnetServer +from prompt_toolkit.contrib.ssh.server import ( + PromptToolkitSSHServer, + PromptToolkitSSHSession, +) +from prompt_toolkit.contrib.telnet.server import TelnetConnection, TelnetServer from ptpython.repl import embed -def ensure_key(filename="ssh_host_key"): +def ensure_key(filename: str = "ssh_host_key") -> str: path = pathlib.Path(filename) if not path.exists(): rsa_key = asyncssh.generate_private_key("ssh-rsa") @@ -25,12 +30,12 @@ def ensure_key(filename="ssh_host_key"): return str(path) -async def interact(connection=None): +async def interact(connection: PromptToolkitSSHSession | TelnetConnection) -> None: global_dict = {**globals(), "print": print_formatted_text} await embed(return_asyncio_coroutine=True, globals=global_dict) -async def main(ssh_port=8022, telnet_port=8023): +async def main(ssh_port: int = 8022, telnet_port: int = 8023) -> None: ssh_server = PromptToolkitSSHServer(interact=interact) await asyncssh.create_server( lambda: ssh_server, "", ssh_port, server_host_keys=[ensure_key()] diff --git a/examples/test-cases/ptpython-in-other-thread.py b/examples/test-cases/ptpython-in-other-thread.py index 7c788464..bfe14109 100644 --- a/examples/test-cases/ptpython-in-other-thread.py +++ b/examples/test-cases/ptpython-in-other-thread.py @@ -5,6 +5,7 @@ (For testing whether it's working fine if it's not embedded in the main thread.) """ + import threading from ptpython.repl import embed diff --git a/mypy.ini b/mypy.ini deleted file mode 100644 index 5a7ef2eb..00000000 --- a/mypy.ini +++ /dev/null @@ -1,6 +0,0 @@ -[mypy] -ignore_missing_imports = True -no_implicit_optional = True -platform = win32 -strict_equality = True -strict_optional = True diff --git a/ptpython/repl.py b/ptpython/repl.py deleted file mode 100644 index b55b5d56..00000000 --- a/ptpython/repl.py +++ /dev/null @@ -1,805 +0,0 @@ -""" -Utility for creating a Python repl. - -:: - - from ptpython.repl import embed - embed(globals(), locals(), vi_mode=False) - -""" -import asyncio -import builtins -import os -import sys -import traceback -import types -import warnings -from dis import COMPILER_FLAG_NAMES -from enum import Enum -from typing import Any, Callable, ContextManager, Dict, Optional - -from prompt_toolkit.formatted_text import ( - HTML, - AnyFormattedText, - FormattedText, - PygmentsTokens, - StyleAndTextTuples, - fragment_list_width, - merge_formatted_text, - to_formatted_text, -) -from prompt_toolkit.formatted_text.utils import fragment_list_to_text, split_lines -from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent -from prompt_toolkit.patch_stdout import patch_stdout as patch_stdout_context -from prompt_toolkit.shortcuts import ( - PromptSession, - clear_title, - print_formatted_text, - set_title, -) -from prompt_toolkit.styles import BaseStyle -from prompt_toolkit.utils import DummyContext, get_cwidth -from pygments.lexers import PythonLexer, PythonTracebackLexer -from pygments.token import Token - -from .python_input import PythonInput - -try: - from ast import PyCF_ALLOW_TOP_LEVEL_AWAIT # type: ignore -except ImportError: - PyCF_ALLOW_TOP_LEVEL_AWAIT = 0 - -__all__ = ["PythonRepl", "enable_deprecation_warnings", "run_config", "embed"] - - -def _get_coroutine_flag() -> Optional[int]: - for k, v in COMPILER_FLAG_NAMES.items(): - if v == "COROUTINE": - return k - - # Flag not found. - return None - - -COROUTINE_FLAG: Optional[int] = _get_coroutine_flag() - - -def _has_coroutine_flag(code: types.CodeType) -> bool: - if COROUTINE_FLAG is None: - # Not supported on this Python version. - return False - - return bool(code.co_flags & COROUTINE_FLAG) - - -class PythonRepl(PythonInput): - def __init__(self, *a, **kw) -> None: - self._startup_paths = kw.pop("startup_paths", None) - super().__init__(*a, **kw) - self._load_start_paths() - - def _load_start_paths(self) -> None: - "Start the Read-Eval-Print Loop." - if self._startup_paths: - for path in self._startup_paths: - if os.path.exists(path): - with open(path, "rb") as f: - code = compile(f.read(), path, "exec") - exec(code, self.get_globals(), self.get_locals()) - else: - output = self.app.output - output.write("WARNING | File not found: {}\n\n".format(path)) - - def run_and_show_expression(self, expression): - try: - # Eval. - try: - result = self.eval(expression) - except KeyboardInterrupt: # KeyboardInterrupt doesn't inherit from Exception. - raise - except SystemExit: - raise - except BaseException as e: - self._handle_exception(e) - else: - # Print. - if result is not None: - self.show_result(result) - - # Loop. - self.current_statement_index += 1 - self.signatures = [] - - except KeyboardInterrupt as e: - # Handle all possible `KeyboardInterrupt` errors. This can - # happen during the `eval`, but also during the - # `show_result` if something takes too long. - # (Try/catch is around the whole block, because we want to - # prevent that a Control-C keypress terminates the REPL in - # any case.) - self._handle_keyboard_interrupt(e) - - def run(self) -> None: - """ - Run the REPL loop. - """ - if self.terminal_title: - set_title(self.terminal_title) - - self._add_to_namespace() - - try: - while True: - # Pull text from the user. - try: - text = self.read() - except EOFError: - return - except BaseException as e: - # Something went wrong while reading input. - # (E.g., a bug in the completer that propagates. Don't - # crash the REPL.) - traceback.print_exc() - continue - - # Run it; display the result (or errors if applicable). - self.run_and_show_expression(text) - finally: - if self.terminal_title: - clear_title() - self._remove_from_namespace() - - async def run_and_show_expression_async(self, text): - loop = asyncio.get_event_loop() - - try: - result = await self.eval_async(text) - except KeyboardInterrupt: # KeyboardInterrupt doesn't inherit from Exception. - raise - except SystemExit: - return - except BaseException as e: - self._handle_exception(e) - else: - # Print. - if result is not None: - await loop.run_in_executor(None, lambda: self.show_result(result)) - - # Loop. - self.current_statement_index += 1 - self.signatures = [] - # Return the result for future consumers. - return result - - async def run_async(self) -> None: - """ - Run the REPL loop, but run the blocking parts in an executor, so that - we don't block the event loop. Both the input and output (which can - display a pager) will run in a separate thread with their own event - loop, this way ptpython's own event loop won't interfere with the - asyncio event loop from where this is called. - - The "eval" however happens in the current thread, which is important. - (Both for control-C to work, as well as for the code to see the right - thread in which it was embedded). - """ - loop = asyncio.get_event_loop() - - if self.terminal_title: - set_title(self.terminal_title) - - self._add_to_namespace() - - try: - while True: - try: - # Read. - try: - text = await loop.run_in_executor(None, self.read) - except EOFError: - return - except BaseException: - # Something went wrong while reading input. - # (E.g., a bug in the completer that propagates. Don't - # crash the REPL.) - traceback.print_exc() - continue - - # Eval. - await self.run_and_show_expression_async(text) - - except KeyboardInterrupt as e: - # XXX: This does not yet work properly. In some situations, - # `KeyboardInterrupt` exceptions can end up in the event - # loop selector. - self._handle_keyboard_interrupt(e) - finally: - if self.terminal_title: - clear_title() - self._remove_from_namespace() - - def eval(self, line: str) -> object: - """ - Evaluate the line and print the result. - """ - # WORKAROUND: Due to a bug in Jedi, the current directory is removed - # from sys.path. See: https://github.com/davidhalter/jedi/issues/1148 - if "" not in sys.path: - sys.path.insert(0, "") - - if line.lstrip().startswith("!"): - # Run as shell command - os.system(line[1:]) - else: - # Try eval first - try: - code = self._compile_with_flags(line, "eval") - except SyntaxError: - pass - else: - # No syntax errors for eval. Do eval. - result = eval(code, self.get_globals(), self.get_locals()) - - if _has_coroutine_flag(code): - result = asyncio.get_event_loop().run_until_complete(result) - - self._store_eval_result(result) - return result - - # If not a valid `eval` expression, run using `exec` instead. - # Note that we shouldn't run this in the `except SyntaxError` block - # above, then `sys.exc_info()` would not report the right error. - # See issue: https://github.com/prompt-toolkit/ptpython/issues/435 - code = self._compile_with_flags(line, "exec") - result = eval(code, self.get_globals(), self.get_locals()) - - if _has_coroutine_flag(code): - result = asyncio.get_event_loop().run_until_complete(result) - - return None - - async def eval_async(self, line: str) -> object: - """ - Evaluate the line and print the result. - """ - # WORKAROUND: Due to a bug in Jedi, the current directory is removed - # from sys.path. See: https://github.com/davidhalter/jedi/issues/1148 - if "" not in sys.path: - sys.path.insert(0, "") - - if line.lstrip().startswith("!"): - # Run as shell command - os.system(line[1:]) - else: - # Try eval first - try: - code = self._compile_with_flags(line, "eval") - except SyntaxError: - pass - else: - # No syntax errors for eval. Do eval. - result = eval(code, self.get_globals(), self.get_locals()) - - if _has_coroutine_flag(code): - result = await result - - self._store_eval_result(result) - return result - - # If not a valid `eval` expression, compile as `exec` expression - # but still run with eval to get an awaitable in case of a - # awaitable expression. - code = self._compile_with_flags(line, "exec") - result = eval(code, self.get_globals(), self.get_locals()) - - if _has_coroutine_flag(code): - result = await result - - return None - - def _store_eval_result(self, result: object) -> None: - locals: Dict[str, Any] = self.get_locals() - locals["_"] = locals["_%i" % self.current_statement_index] = result - - def get_compiler_flags(self) -> int: - return super().get_compiler_flags() | PyCF_ALLOW_TOP_LEVEL_AWAIT - - def _compile_with_flags(self, code: str, mode: str): - "Compile code with the right compiler flags." - return compile( - code, - "", - mode, - flags=self.get_compiler_flags(), - dont_inherit=True, - ) - - def _format_result_output(self, result: object) -> StyleAndTextTuples: - """ - Format __repr__ for an `eval` result. - - Note: this can raise `KeyboardInterrupt` if either calling `__repr__`, - `__pt_repr__` or formatting the output with "Black" takes to long - and the user presses Control-C. - """ - out_prompt = to_formatted_text(self.get_output_prompt()) - - # If the repr is valid Python code, use the Pygments lexer. - try: - result_repr = repr(result) - except KeyboardInterrupt: - raise # Don't catch here. - except BaseException as e: - # Calling repr failed. - self._handle_exception(e) - return [] - - try: - compile(result_repr, "", "eval") - except SyntaxError: - formatted_result_repr = to_formatted_text(result_repr) - else: - # Syntactically correct. Format with black and syntax highlight. - if self.enable_output_formatting: - # Inline import. Slightly speed up start-up time if black is - # not used. - try: - import black - - if not hasattr(black, "Mode"): - raise ImportError - except ImportError: - pass # no Black package in your installation - else: - result_repr = black.format_str( - result_repr, - mode=black.Mode(line_length=self.app.output.get_size().columns), - ) - - formatted_result_repr = to_formatted_text( - PygmentsTokens(list(_lex_python_result(result_repr))) - ) - - # If __pt_repr__ is present, take this. This can return prompt_toolkit - # formatted text. - try: - if hasattr(result, "__pt_repr__"): - formatted_result_repr = to_formatted_text( - getattr(result, "__pt_repr__")() - ) - if isinstance(formatted_result_repr, list): - formatted_result_repr = FormattedText(formatted_result_repr) - except KeyboardInterrupt: - raise # Don't catch here. - except: - # For bad code, `__getattr__` can raise something that's not an - # `AttributeError`. This happens already when calling `hasattr()`. - pass - - # Align every line to the prompt. - line_sep = "\n" + " " * fragment_list_width(out_prompt) - indented_repr: StyleAndTextTuples = [] - - lines = list(split_lines(formatted_result_repr)) - - for i, fragment in enumerate(lines): - indented_repr.extend(fragment) - - # Add indentation separator between lines, not after the last line. - if i != len(lines) - 1: - indented_repr.append(("", line_sep)) - - # Write output tokens. - if self.enable_syntax_highlighting: - formatted_output = merge_formatted_text([out_prompt, indented_repr]) - else: - formatted_output = FormattedText( - out_prompt + [("", fragment_list_to_text(formatted_result_repr))] - ) - - return to_formatted_text(formatted_output) - - def show_result(self, result: object) -> None: - """ - Show __repr__ for an `eval` result and print to ouptut. - """ - formatted_text_output = self._format_result_output(result) - - if self.enable_pager: - self.print_paginated_formatted_text(formatted_text_output) - else: - self.print_formatted_text(formatted_text_output) - - self.app.output.flush() - - if self.insert_blank_line_after_output: - self.app.output.write("\n") - - def print_formatted_text( - self, formatted_text: StyleAndTextTuples, end: str = "\n" - ) -> None: - print_formatted_text( - FormattedText(formatted_text), - style=self._current_style, - style_transformation=self.style_transformation, - include_default_pygments_style=False, - output=self.app.output, - end=end, - ) - - def print_paginated_formatted_text( - self, - formatted_text: StyleAndTextTuples, - end: str = "\n", - ) -> None: - """ - Print formatted text, using --MORE-- style pagination. - (Avoid filling up the terminal's scrollback buffer.) - """ - pager_prompt = self.create_pager_prompt() - size = self.app.output.get_size() - - abort = False - print_all = False - - # Max number of lines allowed in the buffer before painting. - max_rows = size.rows - 1 - - # Page buffer. - rows_in_buffer = 0 - columns_in_buffer = 0 - page: StyleAndTextTuples = [] - - def flush_page() -> None: - nonlocal page, columns_in_buffer, rows_in_buffer - self.print_formatted_text(page, end="") - page = [] - columns_in_buffer = 0 - rows_in_buffer = 0 - - def show_pager() -> None: - nonlocal abort, max_rows, print_all - - # Run pager prompt in another thread. - # Same as for the input. This prevents issues with nested event - # loops. - pager_result = pager_prompt.prompt(in_thread=True) - - if pager_result == PagerResult.ABORT: - print("...") - abort = True - - elif pager_result == PagerResult.NEXT_LINE: - max_rows = 1 - - elif pager_result == PagerResult.NEXT_PAGE: - max_rows = size.rows - 1 - - elif pager_result == PagerResult.PRINT_ALL: - print_all = True - - # Loop over lines. Show --MORE-- prompt when page is filled. - - formatted_text = formatted_text + [("", end)] - lines = list(split_lines(formatted_text)) - - for lineno, line in enumerate(lines): - for style, text, *_ in line: - for c in text: - width = get_cwidth(c) - - # (Soft) wrap line if it doesn't fit. - if columns_in_buffer + width > size.columns: - # Show pager first if we get too many lines after - # wrapping. - if rows_in_buffer + 1 >= max_rows and not print_all: - page.append(("", "\n")) - flush_page() - show_pager() - if abort: - return - - rows_in_buffer += 1 - columns_in_buffer = 0 - - columns_in_buffer += width - page.append((style, c)) - - if rows_in_buffer + 1 >= max_rows and not print_all: - page.append(("", "\n")) - flush_page() - show_pager() - if abort: - return - else: - # Add line ending between lines (if `end="\n"` was given, one - # more empty line is added in `split_lines` automatically to - # take care of the final line ending). - if lineno != len(lines) - 1: - page.append(("", "\n")) - rows_in_buffer += 1 - columns_in_buffer = 0 - - flush_page() - - def create_pager_prompt(self) -> PromptSession["PagerResult"]: - """ - Create pager --MORE-- prompt. - """ - return create_pager_prompt(self._current_style, self.title) - - def _format_exception_output(self, e: BaseException) -> PygmentsTokens: - # Instead of just calling ``traceback.format_exc``, we take the - # traceback and skip the bottom calls of this framework. - t, v, tb = sys.exc_info() - - # Required for pdb.post_mortem() to work. - sys.last_type, sys.last_value, sys.last_traceback = t, v, tb - - tblist = list(traceback.extract_tb(tb)) - - for line_nr, tb_tuple in enumerate(tblist): - if tb_tuple[0] == "": - tblist = tblist[line_nr:] - break - - l = traceback.format_list(tblist) - if l: - l.insert(0, "Traceback (most recent call last):\n") - l.extend(traceback.format_exception_only(t, v)) - - tb_str = "".join(l) - - # Format exception and write to output. - # (We use the default style. Most other styles result - # in unreadable colors for the traceback.) - if self.enable_syntax_highlighting: - tokens = list(_lex_python_traceback(tb_str)) - else: - tokens = [(Token, tb_str)] - return PygmentsTokens(tokens) - - def _handle_exception(self, e: BaseException) -> None: - output = self.app.output - - tokens = self._format_exception_output(e) - - print_formatted_text( - tokens, - style=self._current_style, - style_transformation=self.style_transformation, - include_default_pygments_style=False, - output=output, - ) - - output.write("%s\n" % e) - output.flush() - - def _handle_keyboard_interrupt(self, e: KeyboardInterrupt) -> None: - output = self.app.output - - output.write("\rKeyboardInterrupt\n\n") - output.flush() - - def _add_to_namespace(self) -> None: - """ - Add ptpython built-ins to global namespace. - """ - globals = self.get_globals() - - # Add a 'get_ptpython', similar to 'get_ipython' - def get_ptpython() -> PythonInput: - return self - - globals["get_ptpython"] = get_ptpython - - def _remove_from_namespace(self) -> None: - """ - Remove added symbols from the globals. - """ - globals = self.get_globals() - del globals["get_ptpython"] - - -def _lex_python_traceback(tb): - "Return token list for traceback string." - lexer = PythonTracebackLexer() - return lexer.get_tokens(tb) - - -def _lex_python_result(tb): - "Return token list for Python string." - lexer = PythonLexer() - # Use `get_tokens_unprocessed`, so that we get exactly the same string, - # without line endings appended. `print_formatted_text` already appends a - # line ending, and otherwise we'll have two line endings. - tokens = lexer.get_tokens_unprocessed(tb) - return [(tokentype, value) for index, tokentype, value in tokens] - - -def enable_deprecation_warnings() -> None: - """ - Show deprecation warnings, when they are triggered directly by actions in - the REPL. This is recommended to call, before calling `embed`. - - e.g. This will show an error message when the user imports the 'sha' - library on Python 2.7. - """ - warnings.filterwarnings("default", category=DeprecationWarning, module="__main__") - - -def run_config( - repl: PythonInput, config_file: str = "~/.config/ptpython/config.py" -) -> None: - """ - Execute REPL config file. - - :param repl: `PythonInput` instance. - :param config_file: Path of the configuration file. - """ - # Expand tildes. - config_file = os.path.expanduser(config_file) - - def enter_to_continue() -> None: - input("\nPress ENTER to continue...") - - # Check whether this file exists. - if not os.path.exists(config_file): - print("Impossible to read %r" % config_file) - enter_to_continue() - return - - # Run the config file in an empty namespace. - try: - namespace: Dict[str, Any] = {} - - with open(config_file, "rb") as f: - code = compile(f.read(), config_file, "exec") - exec(code, namespace, namespace) - - # Now we should have a 'configure' method in this namespace. We call this - # method with the repl as an argument. - if "configure" in namespace: - namespace["configure"](repl) - - except Exception: - traceback.print_exc() - enter_to_continue() - - -def embed( - globals=None, - locals=None, - configure: Optional[Callable[[PythonRepl], None]] = None, - vi_mode: bool = False, - history_filename: Optional[str] = None, - title: Optional[str] = None, - startup_paths=None, - patch_stdout: bool = False, - return_asyncio_coroutine: bool = False, -) -> None: - """ - Call this to embed Python shell at the current point in your program. - It's similar to `IPython.embed` and `bpython.embed`. :: - - from prompt_toolkit.contrib.repl import embed - embed(globals(), locals()) - - :param vi_mode: Boolean. Use Vi instead of Emacs key bindings. - :param configure: Callable that will be called with the `PythonRepl` as a first - argument, to trigger configuration. - :param title: Title to be displayed in the terminal titlebar. (None or string.) - :param patch_stdout: When true, patch `sys.stdout` so that background - threads that are printing will print nicely above the prompt. - """ - # Default globals/locals - if globals is None: - globals = { - "__name__": "__main__", - "__package__": None, - "__doc__": None, - "__builtins__": builtins, - } - - locals = locals or globals - - def get_globals(): - return globals - - def get_locals(): - return locals - - # Create REPL. - repl = PythonRepl( - get_globals=get_globals, - get_locals=get_locals, - vi_mode=vi_mode, - history_filename=history_filename, - startup_paths=startup_paths, - ) - - if title: - repl.terminal_title = title - - if configure: - configure(repl) - - # Start repl. - patch_context: ContextManager = ( - patch_stdout_context() if patch_stdout else DummyContext() - ) - - if return_asyncio_coroutine: - - async def coroutine(): - with patch_context: - await repl.run_async() - - return coroutine() - else: - with patch_context: - repl.run() - - -class PagerResult(Enum): - ABORT = "ABORT" - NEXT_LINE = "NEXT_LINE" - NEXT_PAGE = "NEXT_PAGE" - PRINT_ALL = "PRINT_ALL" - - -def create_pager_prompt( - style: BaseStyle, title: AnyFormattedText = "" -) -> PromptSession[PagerResult]: - """ - Create a "continue" prompt for paginated output. - """ - bindings = KeyBindings() - - @bindings.add("enter") - @bindings.add("down") - def next_line(event: KeyPressEvent) -> None: - event.app.exit(result=PagerResult.NEXT_LINE) - - @bindings.add("space") - def next_page(event: KeyPressEvent) -> None: - event.app.exit(result=PagerResult.NEXT_PAGE) - - @bindings.add("a") - def print_all(event: KeyPressEvent) -> None: - event.app.exit(result=PagerResult.PRINT_ALL) - - @bindings.add("q") - @bindings.add("c-c") - @bindings.add("c-d") - @bindings.add("escape", eager=True) - def no(event: KeyPressEvent) -> None: - event.app.exit(result=PagerResult.ABORT) - - @bindings.add("") - def _(event: KeyPressEvent) -> None: - "Disallow inserting other text." - pass - - style - - session: PromptSession[PagerResult] = PromptSession( - merge_formatted_text( - [ - title, - HTML( - "" - " -- MORE -- " - "[Enter] Scroll " - "[Space] Next page " - "[a] Print all " - "[q] Quit " - ": " - ), - ] - ), - key_bindings=bindings, - erase_when_done=True, - style=style, - ) - return session diff --git a/pyproject.toml b/pyproject.toml index b356239f..cb6d1ed1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,95 @@ -[tool.black] -target-version = ['py36'] - - -[tool.isort] -# isort configuration that is compatible with Black. -multi_line_output = 3 -include_trailing_comma = true -known_first_party = "ptpython" -known_third_party = "prompt_toolkit,pygments,asyncssh" -force_grid_wrap = 0 -use_parentheses = true -line_length = 88 +[project] +name = "ptpython" +version = "3.0.32" +description = "Python REPL build on top of prompt_toolkit" +readme = "README.rst" +authors = [{ name = "Jonathan Slenders" }] +classifiers = [ + "License :: OSI Approved :: BSD License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python", +] +requires-python = ">=3.8" +dependencies = [ + "appdirs", + "jedi>=0.16.0", + # Use prompt_toolkit 3.0.43, because of `OneStyleAndTextTuple` import. + "prompt_toolkit>=3.0.43,<3.1.0", + "pygments", +] + + +[project.urls] +Homepage = "https://github.com/prompt-toolkit/ptpython" +Changelog = "https://github.com/prompt-toolkit/ptpython/blob/master/CHANGELOG" +"Bug Tracker" = "https://github.com/prompt-toolkit/ptpython/issues" +"Source Code" = "https://github.com/prompt-toolkit/ptpython" + + +[project.scripts] +ptpython = "ptpython.entry_points.run_ptpython:run" +ptipython = "ptpython.entry_points.run_ptipython:run" + + +[project.optional-dependencies] +ptipython = ["ipython"] # For ptipython, we need to have IPython + + +[tool.mypy] +ignore_missing_imports = true +no_implicit_optional = true +platform = "win32" +strict_equality = true +strict_optional = true + + +[tool.ruff] +target-version = "py38" +lint.select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "C", # flake8-comprehensions + "T", # Print. + "I", # isort + # "B", # flake8-bugbear + "UP", # pyupgrade + "RUF100", # unused-noqa + "Q", # quotes +] +lint.ignore = [ + "E501", # Line too long, handled by black + "C901", # Too complex + "E722", # bare except. +] + + +[tool.ruff.lint.per-file-ignores] +"examples/*" = ["T201"] # Print allowed in examples. +"examples/ptpython_config/config.py" = ["F401"] # Unused imports in config. +"src/ptpython/entry_points/run_ptipython.py" = ["T201", "F401"] # Print, import usage. +"src/ptpython/entry_points/run_ptpython.py" = ["T201"] # Print usage. +"src/ptpython/ipython.py" = ["T100"] # Import usage. +"src/ptpython/repl.py" = ["T201"] # Print usage. +"src/ptpython/printer.py" = ["T201"] # Print usage. + + +[tool.ruff.lint.isort] +known-first-party = ["ptpython"] +known-third-party = ["prompt_toolkit", "pygments", "asyncssh"] + +[tool.typos.default] +extend-ignore-re = [ + "impotr" # Intentional typo in: ./examples/ptpython_config/config.py +] + +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 3c6e79cf..00000000 --- a/setup.cfg +++ /dev/null @@ -1,2 +0,0 @@ -[bdist_wheel] -universal=1 diff --git a/setup.py b/setup.py deleted file mode 100644 index a8214f27..00000000 --- a/setup.py +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env python -import os -import sys - -from setuptools import find_packages, setup - -with open(os.path.join(os.path.dirname(__file__), "README.rst")) as f: - long_description = f.read() - - -setup( - name="ptpython", - author="Jonathan Slenders", - version="3.0.20", - url="https://github.com/prompt-toolkit/ptpython", - description="Python REPL build on top of prompt_toolkit", - long_description=long_description, - packages=find_packages("."), - package_data={"ptpython": ["py.typed"]}, - install_requires=[ - "appdirs", - "importlib_metadata;python_version<'3.8'", - "jedi>=0.16.0", - # Use prompt_toolkit 3.0.18, because of the `in_thread` option. - "prompt_toolkit>=3.0.18,<3.1.0", - "pygments", - ], - python_requires=">=3.6", - classifiers=[ - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python", - ], - entry_points={ - "console_scripts": [ - "ptpython = ptpython.entry_points.run_ptpython:run", - "ptipython = ptpython.entry_points.run_ptipython:run", - "ptpython%s = ptpython.entry_points.run_ptpython:run" % sys.version_info[0], - "ptpython%s.%s = ptpython.entry_points.run_ptpython:run" - % sys.version_info[:2], - "ptipython%s = ptpython.entry_points.run_ptipython:run" - % sys.version_info[0], - "ptipython%s.%s = ptpython.entry_points.run_ptipython:run" - % sys.version_info[:2], - ] - }, - extras_require={ - "ptipython": ["ipython"], # For ptipython, we need to have IPython - "all": ["black"], # Black not always possible on PyPy - }, -) diff --git a/ptpython/__init__.py b/src/ptpython/__init__.py similarity index 55% rename from ptpython/__init__.py rename to src/ptpython/__init__.py index 4908eba8..63c6233d 100644 --- a/ptpython/__init__.py +++ b/src/ptpython/__init__.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from .repl import embed __all__ = ["embed"] diff --git a/ptpython/__main__.py b/src/ptpython/__main__.py similarity index 76% rename from ptpython/__main__.py rename to src/ptpython/__main__.py index 83340a7b..3a2f7ddf 100644 --- a/ptpython/__main__.py +++ b/src/ptpython/__main__.py @@ -1,6 +1,9 @@ """ Make `python -m ptpython` an alias for running `./ptpython`. """ + +from __future__ import annotations + from .entry_points.run_ptpython import run run() diff --git a/ptpython/completer.py b/src/ptpython/completer.py similarity index 85% rename from ptpython/completer.py rename to src/ptpython/completer.py index 51a4086b..40701cab 100644 --- a/ptpython/completer.py +++ b/src/ptpython/completer.py @@ -1,10 +1,13 @@ +from __future__ import annotations + import ast import collections.abc as collections_abc import inspect import keyword import re from enum import Enum -from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, List, Optional +from itertools import islice +from typing import TYPE_CHECKING, Any, Callable, Iterable from prompt_toolkit.completion import ( CompleteEvent, @@ -21,6 +24,7 @@ from ptpython.utils import get_jedi_script_from_document if TYPE_CHECKING: + import jedi.api.classes from prompt_toolkit.contrib.regular_languages.compiler import _CompiledGrammar __all__ = ["PythonCompleter", "CompletePrivateAttributes", "HidePrivateCompleter"] @@ -43,8 +47,8 @@ class PythonCompleter(Completer): def __init__( self, - get_globals: Callable[[], dict], - get_locals: Callable[[], dict], + get_globals: Callable[[], dict[str, Any]], + get_locals: Callable[[], dict[str, Any]], enable_dictionary_completion: Callable[[], bool], ) -> None: super().__init__() @@ -57,8 +61,8 @@ def __init__( self._jedi_completer = JediCompleter(get_globals, get_locals) self._dictionary_completer = DictionaryCompleter(get_globals, get_locals) - self._path_completer_cache: Optional[GrammarCompleter] = None - self._path_completer_grammar_cache: Optional["_CompiledGrammar"] = None + self._path_completer_cache: GrammarCompleter | None = None + self._path_completer_grammar_cache: _CompiledGrammar | None = None @property def _path_completer(self) -> GrammarCompleter: @@ -73,7 +77,7 @@ def _path_completer(self) -> GrammarCompleter: return self._path_completer_cache @property - def _path_completer_grammar(self) -> "_CompiledGrammar": + def _path_completer_grammar(self) -> _CompiledGrammar: """ Return the grammar for matching paths inside strings inside Python code. @@ -84,7 +88,7 @@ def _path_completer_grammar(self) -> "_CompiledGrammar": self._path_completer_grammar_cache = self._create_path_completer_grammar() return self._path_completer_grammar_cache - def _create_path_completer_grammar(self) -> "_CompiledGrammar": + def _create_path_completer_grammar(self) -> _CompiledGrammar: def unwrapper(text: str) -> str: return re.sub(r"\\(.)", r"\1", text) @@ -188,7 +192,6 @@ def get_completions( ): # If we are inside a string, Don't do Jedi completion. if not self._path_completer_grammar.match(document.text_before_cursor): - # Do Jedi Python completions. yield from self._jedi_completer.get_completions( document, complete_event @@ -200,7 +203,11 @@ class JediCompleter(Completer): Autocompleter that uses the Jedi library. """ - def __init__(self, get_globals, get_locals) -> None: + def __init__( + self, + get_globals: Callable[[], dict[str, Any]], + get_locals: Callable[[], dict[str, Any]], + ) -> None: super().__init__() self.get_globals = get_globals @@ -237,7 +244,7 @@ def get_completions( # Jedi issue: "KeyError: u'a_lambda'." # https://github.com/jonathanslenders/ptpython/issues/89 pass - except IOError: + except OSError: # Jedi issue: "IOError: No such file or directory." # https://github.com/jonathanslenders/ptpython/issues/71 pass @@ -253,7 +260,7 @@ def get_completions( # See: https://github.com/jonathanslenders/ptpython/issues/223 pass except Exception: - # Supress all other Jedi exceptions. + # Suppress all other Jedi exceptions. pass else: # Move function parameters to the top. @@ -278,12 +285,17 @@ def get_completions( if jc.type == "param": suffix = "..." + style = _get_style_for_jedi_completion(jc) + display_meta = ( + jc.type if style == "" else [(f"{style}-meta", jc.type)] + ) + yield Completion( jc.name_with_symbols, len(jc.complete) - len(jc.name_with_symbols), display=jc.name_with_symbols + suffix, - display_meta=jc.type, - style=_get_style_for_jedi_completion(jc), + display_meta=display_meta, + style=style, ) @@ -296,7 +308,11 @@ class DictionaryCompleter(Completer): function calls, so it only triggers attribute access. """ - def __init__(self, get_globals, get_locals): + def __init__( + self, + get_globals: Callable[[], dict[str, Any]], + get_locals: Callable[[], dict[str, Any]], + ) -> None: super().__init__() self.get_globals = get_globals @@ -357,7 +373,7 @@ def __init__(self, get_globals, get_locals): rf""" {expression} - # Dict loopup to complete (square bracket open + start of + # Dict lookup to complete (square bracket open + start of # string). \[ \s* ([^\[\]]*)$ @@ -370,14 +386,14 @@ def __init__(self, get_globals, get_locals): rf""" {expression} - # Attribute loopup to complete (dot + varname). + # Attribute lookup to complete (dot + varname). \. \s* ([a-zA-Z0-9_]*)$ """, re.VERBOSE, ) - def _lookup(self, expression: str, temp_locals: Dict[str, Any]) -> object: + def _lookup(self, expression: str, temp_locals: dict[str, Any]) -> object: """ Do lookup of `object_var` in the context. `temp_locals` is a dictionary, used for the locals. @@ -390,7 +406,6 @@ def _lookup(self, expression: str, temp_locals: Dict[str, Any]) -> object: def get_completions( self, document: Document, complete_event: CompleteEvent ) -> Iterable[Completion]: - # First, find all for-loops, and assign the first item of the # collections they're iterating to the iterator variable, so that we # can provide code completion on the iterators. @@ -422,7 +437,7 @@ def _do_repr(self, obj: object) -> str: except BaseException: raise ReprFailedError - def eval_expression(self, document: Document, locals: Dict[str, Any]) -> object: + def eval_expression(self, document: Document, locals: dict[str, Any]) -> object: """ Evaluate """ @@ -437,7 +452,7 @@ def _get_expression_completions( self, document: Document, complete_event: CompleteEvent, - temp_locals: Dict[str, Any], + temp_locals: dict[str, Any], ) -> Iterable[Completion]: """ Complete the [ or . operator after an object. @@ -445,7 +460,6 @@ def _get_expression_completions( result = self.eval_expression(document, temp_locals) if result is not None: - if isinstance( result, (list, tuple, dict, collections_abc.Mapping, collections_abc.Sequence), @@ -461,20 +475,43 @@ def _get_item_lookup_completions( self, document: Document, complete_event: CompleteEvent, - temp_locals: Dict[str, Any], + temp_locals: dict[str, Any], ) -> Iterable[Completion]: """ Complete dictionary keys. """ - def abbr_meta(text: str) -> str: + def meta_repr(obj: object, key: object) -> Callable[[], str]: "Abbreviate meta text, make sure it fits on one line." - # Take first line, if multiple lines. - if len(text) > 20: - text = text[:20] + "..." - if "\n" in text: - text = text.split("\n", 1)[0] + "..." - return text + cached_result: str | None = None + + # We return a function, so that it gets computed when it's needed. + # When there are many completions, that improves the performance + # quite a bit (for the multi-column completion menu, we only need + # to display one meta text). + # Note that we also do the lookup itself in here (`obj[key]`), + # because this part can also be slow for some mapping + # implementations. + def get_value_repr() -> str: + nonlocal cached_result + if cached_result is not None: + return cached_result + + try: + value = obj[key] # type: ignore + + text = self._do_repr(value) + except BaseException: + return "-" + + # Take first line, if multiple lines. + if "\n" in text: + text = text.split("\n", 1)[0] + "..." + + cached_result = text + return text + + return get_value_repr match = self.item_lookup_pattern.search(document.text_before_cursor) if match is not None: @@ -486,29 +523,25 @@ def abbr_meta(text: str) -> str: # If this object is a dictionary, complete the keys. if isinstance(result, (dict, collections_abc.Mapping)): # Try to evaluate the key. - key_obj = key + key_obj_str = str(key) for k in [key, key + '"', key + "'"]: try: - key_obj = ast.literal_eval(k) + key_obj_str = str(ast.literal_eval(k)) except (SyntaxError, ValueError): continue else: break for k in result: - if str(k).startswith(str(key_obj)): + if str(k).startswith(key_obj_str): try: k_repr = self._do_repr(k) yield Completion( k_repr + "]", -len(key), display=f"[{k_repr}]", - display_meta=abbr_meta(self._do_repr(result[k])), + display_meta=meta_repr(result, k), ) - except KeyError: - # `result[k]` lookup failed. Trying to complete - # broken object. - pass except ReprFailedError: pass @@ -523,7 +556,7 @@ def abbr_meta(text: str) -> str: k_repr + "]", -len(key), display=f"[{k_repr}]", - display_meta=abbr_meta(self._do_repr(result[k])), + display_meta=meta_repr(result, k), ) except KeyError: # `result[k]` lookup failed. Trying to complete @@ -536,7 +569,7 @@ def _get_attribute_completions( self, document: Document, complete_event: CompleteEvent, - temp_locals: Dict[str, Any], + temp_locals: dict[str, Any], ) -> Iterable[Completion]: """ Complete attribute names. @@ -555,9 +588,9 @@ def get_suffix(name: str) -> str: obj = getattr(result, name, None) if inspect.isfunction(obj) or inspect.ismethod(obj): return "()" - if isinstance(obj, dict): + if isinstance(obj, collections_abc.Mapping): return "{}" - if isinstance(obj, (list, tuple)): + if isinstance(obj, collections_abc.Sequence): return "[]" except: pass @@ -568,13 +601,13 @@ def get_suffix(name: str) -> str: suffix = get_suffix(name) yield Completion(name, -len(attr_name), display=name + suffix) - def _sort_attribute_names(self, names: List[str]) -> List[str]: + def _sort_attribute_names(self, names: list[str]) -> list[str]: """ Sort attribute names alphabetically, but move the double underscore and underscore names to the end. """ - def sort_key(name: str): + def sort_key(name: str) -> tuple[int, str]: if name.startswith("__"): return (2, name) # Double underscore comes latest. if name.startswith("_"): @@ -586,7 +619,7 @@ def sort_key(name: str): class HidePrivateCompleter(Completer): """ - Wrapper around completer that hides private fields, deponding on whether or + Wrapper around completer that hides private fields, depending on whether or not public fields are shown. (The reason this is implemented as a `Completer` wrapper is because this @@ -604,8 +637,10 @@ def __init__( def get_completions( self, document: Document, complete_event: CompleteEvent ) -> Iterable[Completion]: - - completions = list(self.completer.get_completions(document, complete_event)) + completions = list( + # Limit at 5k completions for performance. + islice(self.completer.get_completions(document, complete_event), 0, 5000) + ) complete_private_attributes = self.complete_private_attributes() hide_private = False @@ -639,7 +674,9 @@ class ReprFailedError(Exception): _builtin_names = [] -def _get_style_for_jedi_completion(jedi_completion) -> str: +def _get_style_for_jedi_completion( + jedi_completion: jedi.api.classes.Completion, +) -> str: """ Return completion style to use for this name. """ diff --git a/ptpython/contrib/__init__.py b/src/ptpython/contrib/__init__.py similarity index 100% rename from ptpython/contrib/__init__.py rename to src/ptpython/contrib/__init__.py diff --git a/ptpython/contrib/asyncssh_repl.py b/src/ptpython/contrib/asyncssh_repl.py similarity index 79% rename from ptpython/contrib/asyncssh_repl.py rename to src/ptpython/contrib/asyncssh_repl.py index 4c36217d..a86737b6 100644 --- a/ptpython/contrib/asyncssh_repl.py +++ b/src/ptpython/contrib/asyncssh_repl.py @@ -6,21 +6,24 @@ should make sure not to use Python 3-only syntax, because this package should be installable in Python 2 as well! """ + +from __future__ import annotations + import asyncio -from typing import Any, Optional, TextIO, cast +from typing import Any, AnyStr, TextIO, cast import asyncssh from prompt_toolkit.data_structures import Size from prompt_toolkit.input import create_pipe_input from prompt_toolkit.output.vt100 import Vt100_Output -from ptpython.python_input import _GetNamespace +from ptpython.python_input import _GetNamespace, _Namespace from ptpython.repl import PythonRepl __all__ = ["ReplSSHServerSession"] -class ReplSSHServerSession(asyncssh.SSHServerSession): +class ReplSSHServerSession(asyncssh.SSHServerSession[str]): """ SSH server session that runs a Python REPL. @@ -29,11 +32,11 @@ class ReplSSHServerSession(asyncssh.SSHServerSession): """ def __init__( - self, get_globals: _GetNamespace, get_locals: Optional[_GetNamespace] = None + self, get_globals: _GetNamespace, get_locals: _GetNamespace | None = None ) -> None: self._chan: Any = None - def _globals() -> dict: + def _globals() -> _Namespace: data = get_globals() data.setdefault("print", self._print) return data @@ -77,7 +80,7 @@ def _get_size(self) -> Size: width, height, pixwidth, pixheight = self._chan.get_terminal_size() return Size(rows=height, columns=width) - def connection_made(self, chan): + def connection_made(self, chan: Any) -> None: """ Client connected, run repl in coroutine. """ @@ -87,7 +90,7 @@ def connection_made(self, chan): f = asyncio.ensure_future(self.repl.run_async()) # Close channel when done. - def done(_) -> None: + def done(_: object) -> None: chan.close() self._chan = None @@ -96,24 +99,28 @@ def done(_) -> None: def shell_requested(self) -> bool: return True - def terminal_size_changed(self, width, height, pixwidth, pixheight): + def terminal_size_changed( + self, width: int, height: int, pixwidth: int, pixheight: int + ) -> None: """ When the terminal size changes, report back to CLI. """ self.repl.app._on_resize() - def data_received(self, data, datatype): + def data_received(self, data: AnyStr, datatype: int | None) -> None: """ When data is received, send to inputstream of the CLI and repaint. """ - self._input_pipe.send(data) + self._input_pipe.send(data) # type: ignore - def _print(self, *data, sep=" ", end="\n", file=None) -> None: + def _print( + self, *data: object, sep: str = " ", end: str = "\n", file: Any = None + ) -> None: """ Alternative 'print' function that prints back into the SSH channel. """ # Pop keyword-only arguments. (We cannot use the syntax from the # signature. Otherwise, Python2 will give a syntax error message when # installing.) - data = sep.join(map(str, data)) - self._chan.write(data + end) + data_as_str = sep.join(map(str, data)) + self._chan.write(data_as_str + end) diff --git a/ptpython/entry_points/__init__.py b/src/ptpython/entry_points/__init__.py similarity index 100% rename from ptpython/entry_points/__init__.py rename to src/ptpython/entry_points/__init__.py diff --git a/ptpython/entry_points/run_ptipython.py b/src/ptpython/entry_points/run_ptipython.py similarity index 96% rename from ptpython/entry_points/run_ptipython.py rename to src/ptpython/entry_points/run_ptipython.py index 21d70637..b660a0ac 100644 --- a/ptpython/entry_points/run_ptipython.py +++ b/src/ptpython/entry_points/run_ptipython.py @@ -1,4 +1,6 @@ #!/usr/bin/env python +from __future__ import annotations + import os import sys @@ -58,7 +60,7 @@ def run(user_ns=None): code = compile(f.read(), path, "exec") exec(code, user_ns, user_ns) else: - print("File not found: {}\n\n".format(path)) + print(f"File not found: {path}\n\n") sys.exit(1) # Apply config file diff --git a/ptpython/entry_points/run_ptpython.py b/src/ptpython/entry_points/run_ptpython.py similarity index 87% rename from ptpython/entry_points/run_ptpython.py rename to src/ptpython/entry_points/run_ptpython.py index 5ebe2b95..d083858d 100644 --- a/ptpython/entry_points/run_ptpython.py +++ b/src/ptpython/entry_points/run_ptpython.py @@ -9,6 +9,7 @@ -h, --help show this help message and exit --vi Enable Vi key bindings -i, --interactive Start interactive shell after executing this file. + --asyncio Run an asyncio event loop to support top-level "await". --light-bg Run on a light background (use dark colors for text). --dark-bg Run on a dark background (use light colors for text). --config-file CONFIG_FILE @@ -21,30 +22,33 @@ PTPYTHON_CONFIG_HOME: a configuration directory to use PYTHONSTARTUP: file executed on interactive startup (no default) """ + +from __future__ import annotations + import argparse +import asyncio import os import pathlib import sys +from importlib import metadata from textwrap import dedent -from typing import Tuple +from typing import Protocol import appdirs from prompt_toolkit.formatted_text import HTML from prompt_toolkit.shortcuts import print_formatted_text -from ptpython.repl import embed, enable_deprecation_warnings, run_config +from ptpython.repl import PythonRepl, embed, enable_deprecation_warnings, run_config -try: - from importlib import metadata -except ImportError: - import importlib_metadata as metadata # type: ignore +__all__ = ["create_parser", "get_config_and_history_file", "run"] -__all__ = ["create_parser", "get_config_and_history_file", "run"] +class _SupportsWrite(Protocol): + def write(self, s: str, /) -> object: ... class _Parser(argparse.ArgumentParser): - def print_help(self): + def print_help(self, file: _SupportsWrite | None = None) -> None: super().print_help() print( dedent( @@ -66,16 +70,21 @@ def create_parser() -> _Parser: action="store_true", help="Start interactive shell after executing this file.", ) + parser.add_argument( + "--asyncio", + action="store_true", + help='Run an asyncio event loop to support top-level "await".', + ) parser.add_argument( "--light-bg", action="store_true", help="Run on a light background (use dark colors for text).", - ), + ) parser.add_argument( "--dark-bg", action="store_true", help="Run on a dark background (use light colors for text).", - ), + ) parser.add_argument( "--config-file", type=str, help="Location of configuration file." ) @@ -84,13 +93,13 @@ def create_parser() -> _Parser: "-V", "--version", action="version", - version=metadata.version("ptpython"), # type: ignore + version=metadata.version("ptpython"), ) parser.add_argument("args", nargs="*", help="Script and arguments") return parser -def get_config_and_history_file(namespace: argparse.Namespace) -> Tuple[str, str]: +def get_config_and_history_file(namespace: argparse.Namespace) -> tuple[str, str]: """ Check which config/history files to use, ensure that the directories for these files exist, and return the config and history path. @@ -190,7 +199,7 @@ def run() -> None: enable_deprecation_warnings() # Apply config file - def configure(repl) -> None: + def configure(repl: PythonRepl) -> None: if os.path.exists(config_file): run_config(repl, config_file) @@ -204,7 +213,7 @@ def configure(repl) -> None: import __main__ - embed( + embed_result = embed( # type: ignore vi_mode=a.vi, history_filename=history_file, configure=configure, @@ -212,8 +221,14 @@ def configure(repl) -> None: globals=__main__.__dict__, startup_paths=startup_paths, title="Python REPL (ptpython)", + return_asyncio_coroutine=a.asyncio, ) + if a.asyncio: + print("Starting ptpython asyncio REPL") + print('Use "await" directly instead of "asyncio.run()".') + asyncio.run(embed_result) + if __name__ == "__main__": run() diff --git a/ptpython/eventloop.py b/src/ptpython/eventloop.py similarity index 84% rename from ptpython/eventloop.py rename to src/ptpython/eventloop.py index c841972d..a6462748 100644 --- a/ptpython/eventloop.py +++ b/src/ptpython/eventloop.py @@ -7,25 +7,29 @@ in readline. ``prompt-toolkit`` doesn't understand that input hook, but this will fix it for Tk.) """ + +from __future__ import annotations + import sys import time +from prompt_toolkit.eventloop import InputHookContext + __all__ = ["inputhook"] -def _inputhook_tk(inputhook_context): +def _inputhook_tk(inputhook_context: InputHookContext) -> None: """ Inputhook for Tk. Run the Tk eventloop until prompt-toolkit needs to process the next input. """ # Get the current TK application. - import tkinter - import _tkinter # Keep this imports inline! + import tkinter - root = tkinter._default_root + root = tkinter._default_root # type: ignore - def wait_using_filehandler(): + def wait_using_filehandler() -> None: """ Run the TK eventloop until the file handler that we got from the inputhook becomes readable. @@ -34,7 +38,7 @@ def wait_using_filehandler(): # to process. stop = [False] - def done(*a): + def done(*a: object) -> None: stop[0] = True root.createfilehandler(inputhook_context.fileno(), _tkinter.READABLE, done) @@ -46,7 +50,7 @@ def done(*a): root.deletefilehandler(inputhook_context.fileno()) - def wait_using_polling(): + def wait_using_polling() -> None: """ Windows TK doesn't support 'createfilehandler'. So, run the TK eventloop and poll until input is ready. @@ -65,7 +69,7 @@ def wait_using_polling(): wait_using_polling() -def inputhook(inputhook_context): +def inputhook(inputhook_context: InputHookContext) -> None: # Only call the real input hook when the 'Tkinter' library was loaded. if "Tkinter" in sys.modules or "tkinter" in sys.modules: _inputhook_tk(inputhook_context) diff --git a/ptpython/filters.py b/src/ptpython/filters.py similarity index 87% rename from ptpython/filters.py rename to src/ptpython/filters.py index 1adac135..a2079fd3 100644 --- a/ptpython/filters.py +++ b/src/ptpython/filters.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING from prompt_toolkit.filters import Filter @@ -9,7 +11,8 @@ class PythonInputFilter(Filter): - def __init__(self, python_input: "PythonInput") -> None: + def __init__(self, python_input: PythonInput) -> None: + super().__init__() self.python_input = python_input def __call__(self) -> bool: diff --git a/ptpython/history_browser.py b/src/ptpython/history_browser.py similarity index 85% rename from ptpython/history_browser.py rename to src/ptpython/history_browser.py index b7fe0865..101a6c5c 100644 --- a/ptpython/history_browser.py +++ b/src/ptpython/history_browser.py @@ -4,7 +4,11 @@ `create_history_application` creates an `Application` instance that runs will run as a sub application of the Repl/PythonInput. """ + +from __future__ import annotations + from functools import partial +from typing import TYPE_CHECKING, Callable from prompt_toolkit.application import Application from prompt_toolkit.application.current import get_app @@ -12,8 +16,11 @@ from prompt_toolkit.document import Document from prompt_toolkit.enums import DEFAULT_BUFFER from prompt_toolkit.filters import Condition, has_focus +from prompt_toolkit.formatted_text.base import StyleAndTextTuples from prompt_toolkit.formatted_text.utils import fragment_list_to_text +from prompt_toolkit.history import History from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.key_binding.key_processor import KeyPressEvent from prompt_toolkit.layout.containers import ( ConditionalContainer, Container, @@ -24,13 +31,23 @@ VSplit, Window, WindowAlign, + WindowRenderInfo, +) +from prompt_toolkit.layout.controls import ( + BufferControl, + FormattedTextControl, + UIContent, ) -from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl from prompt_toolkit.layout.dimension import Dimension as D from prompt_toolkit.layout.layout import Layout from prompt_toolkit.layout.margins import Margin, ScrollbarMargin -from prompt_toolkit.layout.processors import Processor, Transformation +from prompt_toolkit.layout.processors import ( + Processor, + Transformation, + TransformationInput, +) from prompt_toolkit.lexers import PygmentsLexer +from prompt_toolkit.mouse_events import MouseEvent from prompt_toolkit.widgets import Frame from prompt_toolkit.widgets.toolbars import ArgToolbar, SearchToolbar from pygments.lexers import Python3Lexer as PythonLexer @@ -40,10 +57,17 @@ from .utils import if_mousedown +if TYPE_CHECKING: + from typing_extensions import TypeAlias + + from .python_input import PythonInput + HISTORY_COUNT = 2000 __all__ = ["HistoryLayout", "PythonHistory"] +E: TypeAlias = KeyPressEvent + HELP_TEXT = """ This interface is meant to select multiple lines from the history and execute them together. @@ -86,6 +110,7 @@ class BORDER: "Box drawing characters." + HORIZONTAL = "\u2501" VERTICAL = "\u2503" TOP_LEFT = "\u250f" @@ -109,7 +134,7 @@ class HistoryLayout: application. """ - def __init__(self, history): + def __init__(self, history: PythonHistory) -> None: search_toolbar = SearchToolbar() self.help_buffer_control = BufferControl( @@ -201,19 +226,19 @@ def __init__(self, history): self.layout = Layout(self.root_container, history_window) -def _get_top_toolbar_fragments(): +def _get_top_toolbar_fragments() -> StyleAndTextTuples: return [("class:status-bar.title", "History browser - Insert from history")] -def _get_bottom_toolbar_fragments(history): +def _get_bottom_toolbar_fragments(history: PythonHistory) -> StyleAndTextTuples: python_input = history.python_input @if_mousedown - def f1(mouse_event): + def f1(mouse_event: MouseEvent) -> None: _toggle_help(history) @if_mousedown - def tab(mouse_event): + def tab(mouse_event: MouseEvent) -> None: _select_other_window(history) return ( @@ -239,14 +264,16 @@ class HistoryMargin(Margin): This displays a green bar for the selected entries. """ - def __init__(self, history): + def __init__(self, history: PythonHistory) -> None: self.history_buffer = history.history_buffer self.history_mapping = history.history_mapping - def get_width(self, ui_content): + def get_width(self, get_ui_content: Callable[[], UIContent]) -> int: return 2 - def create_margin(self, window_render_info, width, height): + def create_margin( + self, window_render_info: WindowRenderInfo, width: int, height: int + ) -> StyleAndTextTuples: document = self.history_buffer.document lines_starting_new_entries = self.history_mapping.lines_starting_new_entries @@ -255,7 +282,7 @@ def create_margin(self, window_render_info, width, height): current_lineno = document.cursor_position_row visible_line_to_input_line = window_render_info.visible_line_to_input_line - result = [] + result: StyleAndTextTuples = [] for y in range(height): line_number = visible_line_to_input_line.get(y) @@ -286,14 +313,16 @@ class ResultMargin(Margin): The margin to be shown in the result pane. """ - def __init__(self, history): + def __init__(self, history: PythonHistory) -> None: self.history_mapping = history.history_mapping self.history_buffer = history.history_buffer - def get_width(self, ui_content): + def get_width(self, get_ui_content: Callable[[], UIContent]) -> int: return 2 - def create_margin(self, window_render_info, width, height): + def create_margin( + self, window_render_info: WindowRenderInfo, width: int, height: int + ) -> StyleAndTextTuples: document = self.history_buffer.document current_lineno = document.cursor_position_row @@ -303,7 +332,7 @@ def create_margin(self, window_render_info, width, height): visible_line_to_input_line = window_render_info.visible_line_to_input_line - result = [] + result: StyleAndTextTuples = [] for y in range(height): line_number = visible_line_to_input_line.get(y) @@ -324,7 +353,7 @@ def create_margin(self, window_render_info, width, height): return result - def invalidation_hash(self, document): + def invalidation_hash(self, document: Document) -> int: return document.cursor_position_row @@ -333,13 +362,15 @@ class GrayExistingText(Processor): Turn the existing input, before and after the inserted code gray. """ - def __init__(self, history_mapping): + def __init__(self, history_mapping: HistoryMapping) -> None: self.history_mapping = history_mapping self._lines_before = len( history_mapping.original_document.text_before_cursor.splitlines() ) - def apply_transformation(self, transformation_input): + def apply_transformation( + self, transformation_input: TransformationInput + ) -> Transformation: lineno = transformation_input.lineno fragments = transformation_input.fragments @@ -357,17 +388,22 @@ class HistoryMapping: Keep a list of all the lines from the history and the selected lines. """ - def __init__(self, history, python_history, original_document): + def __init__( + self, + history: PythonHistory, + python_history: History, + original_document: Document, + ) -> None: self.history = history self.python_history = python_history self.original_document = original_document self.lines_starting_new_entries = set() - self.selected_lines = set() + self.selected_lines: set[int] = set() # Process history. history_strings = python_history.get_strings() - history_lines = [] + history_lines: list[str] = [] for entry_nr, entry in list(enumerate(history_strings))[-HISTORY_COUNT:]: self.lines_starting_new_entries.add(len(history_lines)) @@ -377,7 +413,7 @@ def __init__(self, history, python_history, original_document): if len(history_strings) > HISTORY_COUNT: history_lines[0] = ( - "# *** History has been truncated to %s lines ***" % HISTORY_COUNT + f"# *** History has been truncated to {HISTORY_COUNT} lines ***" ) self.history_lines = history_lines @@ -389,7 +425,7 @@ def __init__(self, history, python_history, original_document): else: self.result_line_offset = 0 - def get_new_document(self, cursor_pos=None): + def get_new_document(self, cursor_pos: int | None = None) -> Document: """ Create a `Document` instance that contains the resulting text. """ @@ -413,13 +449,13 @@ def get_new_document(self, cursor_pos=None): cursor_pos = len(text) return Document(text, cursor_pos) - def update_default_buffer(self): + def update_default_buffer(self) -> None: b = self.history.default_buffer b.set_document(self.get_new_document(b.cursor_position), bypass_readonly=True) -def _toggle_help(history): +def _toggle_help(history: PythonHistory) -> None: "Display/hide help." help_buffer_control = history.history_layout.help_buffer_control @@ -429,7 +465,7 @@ def _toggle_help(history): history.app.layout.current_control = help_buffer_control -def _select_other_window(history): +def _select_other_window(history: PythonHistory) -> None: "Toggle focus between left/right window." current_buffer = history.app.current_buffer layout = history.history_layout.layout @@ -441,7 +477,11 @@ def _select_other_window(history): layout.current_control = history.history_layout.history_buffer_control -def create_key_bindings(history, python_input, history_mapping): +def create_key_bindings( + history: PythonHistory, + python_input: PythonInput, + history_mapping: HistoryMapping, +) -> KeyBindings: """ Key bindings. """ @@ -449,7 +489,7 @@ def create_key_bindings(history, python_input, history_mapping): handle = bindings.add @handle(" ", filter=has_focus(history.history_buffer)) - def _(event): + def _(event: E) -> None: """ Space: select/deselect line from history pane. """ @@ -486,7 +526,7 @@ def _(event): @handle(" ", filter=has_focus(DEFAULT_BUFFER)) @handle("delete", filter=has_focus(DEFAULT_BUFFER)) @handle("c-h", filter=has_focus(DEFAULT_BUFFER)) - def _(event): + def _(event: E) -> None: """ Space: remove line from default pane. """ @@ -512,17 +552,17 @@ def _(event): @handle("c-x", filter=main_buffer_focussed, eager=True) # Eager: ignore the Emacs [Ctrl-X Ctrl-X] binding. @handle("c-w", filter=main_buffer_focussed) - def _(event): + def _(event: E) -> None: "Select other window." _select_other_window(history) @handle("f4") - def _(event): + def _(event: E) -> None: "Switch between Emacs/Vi mode." python_input.vi_mode = not python_input.vi_mode @handle("f1") - def _(event): + def _(event: E) -> None: "Display/hide help." _toggle_help(history) @@ -530,7 +570,7 @@ def _(event): @handle("c-c", filter=help_focussed) @handle("c-g", filter=help_focussed) @handle("escape", filter=help_focussed) - def _(event): + def _(event: E) -> None: "Leave help." event.app.layout.focus_previous() @@ -538,19 +578,19 @@ def _(event): @handle("f3", filter=main_buffer_focussed) @handle("c-c", filter=main_buffer_focussed) @handle("c-g", filter=main_buffer_focussed) - def _(event): + def _(event: E) -> None: "Cancel and go back." event.app.exit(result=None) @handle("enter", filter=main_buffer_focussed) - def _(event): + def _(event: E) -> None: "Accept input." event.app.exit(result=history.default_buffer.text) enable_system_bindings = Condition(lambda: python_input.enable_system_bindings) @handle("c-z", filter=enable_system_bindings) - def _(event): + def _(event: E) -> None: "Suspend to background." event.app.suspend_to_background() @@ -558,12 +598,12 @@ def _(event): class PythonHistory: - def __init__(self, python_input, original_document): + def __init__(self, python_input: PythonInput, original_document: Document) -> None: """ Create an `Application` for the history screen. This has to be run as a sub application of `python_input`. - When this application runs and returns, it retuns the selected lines. + When this application runs and returns, it returns the selected lines. """ self.python_input = python_input @@ -577,12 +617,14 @@ def __init__(self, python_input, original_document): + document.get_start_of_line_position(), ) + def accept_handler(buffer: Buffer) -> bool: + get_app().exit(result=self.default_buffer.text) + return False + self.history_buffer = Buffer( document=document, on_cursor_position_changed=self._history_buffer_pos_changed, - accept_handler=( - lambda buff: get_app().exit(result=self.default_buffer.text) - ), + accept_handler=accept_handler, read_only=True, ) @@ -597,15 +639,16 @@ def __init__(self, python_input, original_document): self.history_layout = HistoryLayout(self) - self.app = Application( + self.app: Application[str] = Application( layout=self.history_layout.layout, full_screen=True, style=python_input._current_style, + style_transformation=python_input.style_transformation, mouse_support=Condition(lambda: python_input.enable_mouse_support), key_bindings=create_key_bindings(self, python_input, history_mapping), ) - def _default_buffer_pos_changed(self, _): + def _default_buffer_pos_changed(self, _: Buffer) -> None: """When the cursor changes in the default buffer. Synchronize with history buffer.""" # Only when this buffer has the focus. @@ -629,7 +672,7 @@ def _default_buffer_pos_changed(self, _): ) ) - def _history_buffer_pos_changed(self, _): + def _history_buffer_pos_changed(self, _: Buffer) -> None: """When the cursor changes in the history buffer. Synchronize.""" # Only when this buffer has the focus. if self.app.current_buffer == self.history_buffer: diff --git a/ptpython/ipython.py b/src/ptpython/ipython.py similarity index 81% rename from ptpython/ipython.py rename to src/ptpython/ipython.py index 91633340..0692214d 100644 --- a/ptpython/ipython.py +++ b/src/ptpython/ipython.py @@ -8,13 +8,18 @@ offer. """ + +from __future__ import annotations + +from typing import Iterable from warnings import warn from IPython import utils as ipy_utils -from IPython.core.inputsplitter import IPythonInputSplitter +from IPython.core.inputtransformer2 import TransformerManager from IPython.terminal.embed import InteractiveShellEmbed as _InteractiveShellEmbed from IPython.terminal.ipapp import load_default_config from prompt_toolkit.completion import ( + CompleteEvent, Completer, Completion, PathCompleter, @@ -25,15 +30,18 @@ from prompt_toolkit.contrib.regular_languages.completion import GrammarCompleter from prompt_toolkit.contrib.regular_languages.lexer import GrammarLexer from prompt_toolkit.document import Document -from prompt_toolkit.formatted_text import PygmentsTokens +from prompt_toolkit.formatted_text import AnyFormattedText, PygmentsTokens from prompt_toolkit.lexers import PygmentsLexer, SimpleLexer from prompt_toolkit.styles import Style from pygments.lexers import BashLexer, PythonLexer from ptpython.prompt_style import PromptStyle -from .python_input import PythonCompleter, PythonInput, PythonValidator +from .completer import PythonCompleter +from .python_input import PythonInput +from .repl import PyCF_ALLOW_TOP_LEVEL_AWAIT from .style import default_ui_style +from .validator import PythonValidator __all__ = ["embed"] @@ -46,24 +54,24 @@ class IPythonPrompt(PromptStyle): def __init__(self, prompts): self.prompts = prompts - def in_prompt(self): + def in_prompt(self) -> AnyFormattedText: return PygmentsTokens(self.prompts.in_prompt_tokens()) - def in2_prompt(self, width): + def in2_prompt(self, width: int) -> AnyFormattedText: return PygmentsTokens(self.prompts.continuation_prompt_tokens()) - def out_prompt(self): + def out_prompt(self) -> AnyFormattedText: return [] class IPythonValidator(PythonValidator): def __init__(self, *args, **kwargs): - super(IPythonValidator, self).__init__(*args, **kwargs) - self.isp = IPythonInputSplitter() + super().__init__(*args, **kwargs) + self.isp = TransformerManager() - def validate(self, document): + def validate(self, document: Document) -> None: document = Document(text=self.isp.transform_cell(document.text)) - super(IPythonValidator, self).validate(document) + super().validate(document) def create_ipython_grammar(): @@ -142,26 +150,30 @@ class MagicsCompleter(Completer): def __init__(self, magics_manager): self.magics_manager = magics_manager - def get_completions(self, document, complete_event): + def get_completions( + self, document: Document, complete_event: CompleteEvent + ) -> Iterable[Completion]: text = document.text_before_cursor.lstrip() for m in sorted(self.magics_manager.magics["line"]): if m.startswith(text): - yield Completion("%s" % m, -len(text)) + yield Completion(f"{m}", -len(text)) class AliasCompleter(Completer): def __init__(self, alias_manager): self.alias_manager = alias_manager - def get_completions(self, document, complete_event): + def get_completions( + self, document: Document, complete_event: CompleteEvent + ) -> Iterable[Completion]: text = document.text_before_cursor.lstrip() # aliases = [a for a, _ in self.alias_manager.aliases] aliases = self.alias_manager.aliases for a, cmd in sorted(aliases, key=lambda a: a[0]): if a.startswith(text): - yield Completion("%s" % a, -len(text), display_meta=cmd) + yield Completion(f"{a}", -len(text), display_meta=cmd) class IPythonInput(PythonInput): @@ -201,6 +213,12 @@ def __init__(self, ipython_shell, *a, **kw): self.ui_styles = {"default": Style.from_dict(style_dict)} self.use_ui_colorscheme("default") + def get_compiler_flags(self): + flags = super().get_compiler_flags() + if self.ipython_shell.autoawait: + flags |= PyCF_ALLOW_TOP_LEVEL_AWAIT + return flags + class InteractiveShellEmbed(_InteractiveShellEmbed): """ @@ -240,7 +258,7 @@ def get_globals(): self.python_input = python_input - def prompt_for_code(self): + def prompt_for_code(self) -> str: try: return self.python_input.app.run() except KeyboardInterrupt: @@ -262,13 +280,31 @@ def initialize_extensions(shell, extensions): shell.extension_manager.load_extension(ext) except: warn( - "Error in loading extension: %s" % ext - + "\nCheck your config files in %s" - % ipy_utils.path.get_ipython_dir() + f"Error in loading extension: {ext}" + + f"\nCheck your config files in {ipy_utils.path.get_ipython_dir()}" ) shell.showtraceback() +def run_exec_lines(shell, exec_lines): + """ + Partial copy of run_exec_lines code from IPython.core.shellapp . + """ + try: + iter(exec_lines) + except TypeError: + pass + else: + try: + for line in exec_lines: + try: + shell.run_cell(line, store_history=False) + except: + shell.showtraceback() + except: + shell.showtraceback() + + def embed(**kwargs): """ Copied from `IPython/terminal/embed.py`, but using our `InteractiveShellEmbed` instead. @@ -282,6 +318,7 @@ def embed(**kwargs): kwargs["config"] = config shell = InteractiveShellEmbed.instance(**kwargs) initialize_extensions(shell, config["InteractiveShellApp"]["extensions"]) + run_exec_lines(shell, config["InteractiveShellApp"]["exec_lines"]) run_startup_scripts(shell) shell(header=header, stack_depth=2, compile_flags=compile_flags) diff --git a/ptpython/key_bindings.py b/src/ptpython/key_bindings.py similarity index 87% rename from ptpython/key_bindings.py rename to src/ptpython/key_bindings.py index ae23a3df..48c5f5ae 100644 --- a/ptpython/key_bindings.py +++ b/src/ptpython/key_bindings.py @@ -1,4 +1,9 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + from prompt_toolkit.application import get_app +from prompt_toolkit.buffer import Buffer from prompt_toolkit.document import Document from prompt_toolkit.enums import DEFAULT_BUFFER from prompt_toolkit.filters import ( @@ -11,19 +16,27 @@ ) from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit.key_binding.bindings.named_commands import get_by_name +from prompt_toolkit.key_binding.key_processor import KeyPressEvent from prompt_toolkit.keys import Keys from .utils import document_is_multiline_python +if TYPE_CHECKING: + from typing_extensions import TypeAlias + + from .python_input import PythonInput + __all__ = [ "load_python_bindings", "load_sidebar_bindings", "load_confirm_exit_bindings", ] +E: TypeAlias = KeyPressEvent + @Condition -def tab_should_insert_whitespace(): +def tab_should_insert_whitespace() -> bool: """ When the 'tab' key is pressed with only whitespace character before the cursor, do autocompletion. Otherwise, insert indentation. @@ -38,7 +51,7 @@ def tab_should_insert_whitespace(): return bool(b.text and (not before_cursor or before_cursor.isspace())) -def load_python_bindings(python_input): +def load_python_bindings(python_input: PythonInput) -> KeyBindings: """ Custom key bindings. """ @@ -48,14 +61,14 @@ def load_python_bindings(python_input): handle = bindings.add @handle("c-l") - def _(event): + def _(event: E) -> None: """ Clear whole screen and render again -- also when the sidebar is visible. """ event.app.renderer.clear() @handle("c-z") - def _(event): + def _(event: E) -> None: """ Suspend. """ @@ -67,7 +80,7 @@ def _(event): handle("c-w")(get_by_name("backward-kill-word")) @handle("f2") - def _(event): + def _(event: E) -> None: """ Show/hide sidebar. """ @@ -78,21 +91,21 @@ def _(event): event.app.layout.focus_last() @handle("f3") - def _(event): + def _(event: E) -> None: """ Select from the history. """ python_input.enter_history() @handle("f4") - def _(event): + def _(event: E) -> None: """ Toggle between Vi and Emacs mode. """ python_input.vi_mode = not python_input.vi_mode @handle("f6") - def _(event): + def _(event: E) -> None: """ Enable/Disable paste mode. """ @@ -101,14 +114,14 @@ def _(event): @handle( "tab", filter=~sidebar_visible & ~has_selection & tab_should_insert_whitespace ) - def _(event): + def _(event: E) -> None: """ When tab should insert whitespace, do that instead of completion. """ event.app.current_buffer.insert_text(" ") @Condition - def is_multiline(): + def is_multiline() -> bool: return document_is_multiline_python(python_input.default_buffer.document) @handle( @@ -120,7 +133,7 @@ def is_multiline(): & ~is_multiline, ) @handle(Keys.Escape, Keys.Enter, filter=~sidebar_visible & emacs_mode) - def _(event): + def _(event: E) -> None: """ Accept input (for single line input). """ @@ -143,21 +156,21 @@ def _(event): & has_focus(DEFAULT_BUFFER) & is_multiline, ) - def _(event): + def _(event: E) -> None: """ Behaviour of the Enter key. Auto indent after newline/Enter. - (When not in Vi navigaton mode, and when multiline is enabled.) + (When not in Vi navigation mode, and when multiline is enabled.) """ b = event.current_buffer empty_lines_required = python_input.accept_input_on_enter or 10000 - def at_the_end(b): + def at_the_end(b: Buffer) -> bool: """we consider the cursor at the end when there is no text after the cursor, or only whitespace.""" text = b.document.text_after_cursor - return text == "" or (text.isspace() and not "\n" in text) + return text == "" or (text.isspace() and "\n" not in text) if python_input.paste_mode: # In paste mode, always insert text. @@ -187,7 +200,7 @@ def at_the_end(b): not get_app().current_buffer.text ), ) - def _(event): + def _(event: E) -> None: """ Override Control-D exit, to ask for confirmation. """ @@ -202,14 +215,14 @@ def _(event): event.app.exit(exception=EOFError) @handle("c-c", filter=has_focus(python_input.default_buffer)) - def _(event): + def _(event: E) -> None: "Abort when Control-C has been pressed." event.app.exit(exception=KeyboardInterrupt, style="class:aborting") return bindings -def load_sidebar_bindings(python_input): +def load_sidebar_bindings(python_input: PythonInput) -> KeyBindings: """ Load bindings for the navigation in the sidebar. """ @@ -221,7 +234,7 @@ def load_sidebar_bindings(python_input): @handle("up", filter=sidebar_visible) @handle("c-p", filter=sidebar_visible) @handle("k", filter=sidebar_visible) - def _(event): + def _(event: E) -> None: "Go to previous option." python_input.selected_option_index = ( python_input.selected_option_index - 1 @@ -230,7 +243,7 @@ def _(event): @handle("down", filter=sidebar_visible) @handle("c-n", filter=sidebar_visible) @handle("j", filter=sidebar_visible) - def _(event): + def _(event: E) -> None: "Go to next option." python_input.selected_option_index = ( python_input.selected_option_index + 1 @@ -239,14 +252,14 @@ def _(event): @handle("right", filter=sidebar_visible) @handle("l", filter=sidebar_visible) @handle(" ", filter=sidebar_visible) - def _(event): + def _(event: E) -> None: "Select next value for current option." option = python_input.selected_option option.activate_next() @handle("left", filter=sidebar_visible) @handle("h", filter=sidebar_visible) - def _(event): + def _(event: E) -> None: "Select previous value for current option." option = python_input.selected_option option.activate_previous() @@ -256,7 +269,7 @@ def _(event): @handle("c-d", filter=sidebar_visible) @handle("enter", filter=sidebar_visible) @handle("escape", filter=sidebar_visible) - def _(event): + def _(event: E) -> None: "Hide sidebar." python_input.show_sidebar = False event.app.layout.focus_last() @@ -264,7 +277,7 @@ def _(event): return bindings -def load_confirm_exit_bindings(python_input): +def load_confirm_exit_bindings(python_input: PythonInput) -> KeyBindings: """ Handle yes/no key presses when the exit confirmation is shown. """ @@ -277,14 +290,14 @@ def load_confirm_exit_bindings(python_input): @handle("Y", filter=confirmation_visible) @handle("enter", filter=confirmation_visible) @handle("c-d", filter=confirmation_visible) - def _(event): + def _(event: E) -> None: """ Really quit. """ event.app.exit(exception=EOFError, style="class:exiting") @handle(Keys.Any, filter=confirmation_visible) - def _(event): + def _(event: E) -> None: """ Cancel exit. """ @@ -294,7 +307,7 @@ def _(event): return bindings -def auto_newline(buffer): +def auto_newline(buffer: Buffer) -> None: r""" Insert \n at the cursor position. Also add necessary padding. """ diff --git a/ptpython/layout.py b/src/ptpython/layout.py similarity index 88% rename from ptpython/layout.py rename to src/ptpython/layout.py index dc6b19bb..8d030dd1 100644 --- a/ptpython/layout.py +++ b/src/ptpython/layout.py @@ -1,11 +1,14 @@ """ Creation of the `Layout` instance for the Python input/REPL. """ + +from __future__ import annotations + import platform import sys from enum import Enum from inspect import _ParameterKind as ParameterKind -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Any from prompt_toolkit.application import get_app from prompt_toolkit.enums import DEFAULT_BUFFER, SEARCH_BUFFER @@ -19,6 +22,7 @@ from prompt_toolkit.formatted_text.base import StyleAndTextTuples from prompt_toolkit.key_binding.vi_state import InputMode from prompt_toolkit.layout.containers import ( + AnyContainer, ConditionalContainer, Container, Float, @@ -40,9 +44,10 @@ HighlightIncrementalSearchProcessor, HighlightMatchingBracketProcessor, HighlightSelectionProcessor, + Processor, TabsProcessor, ) -from prompt_toolkit.lexers import SimpleLexer +from prompt_toolkit.lexers import Lexer, SimpleLexer from prompt_toolkit.mouse_events import MouseEvent from prompt_toolkit.selection import SelectionType from prompt_toolkit.widgets.toolbars import ( @@ -52,9 +57,9 @@ SystemToolbar, ValidationToolbar, ) -from pygments.lexers import PythonLexer from .filters import HasSignature, ShowDocstring, ShowSidebar, ShowSignature +from .prompt_style import PromptStyle from .utils import if_mousedown if TYPE_CHECKING: @@ -65,32 +70,33 @@ class CompletionVisualisation(Enum): "Visualisation method for the completions." + NONE = "none" POP_UP = "pop-up" MULTI_COLUMN = "multi-column" TOOLBAR = "toolbar" -def show_completions_toolbar(python_input: "PythonInput") -> Condition: +def show_completions_toolbar(python_input: PythonInput) -> Condition: return Condition( lambda: python_input.completion_visualisation == CompletionVisualisation.TOOLBAR ) -def show_completions_menu(python_input: "PythonInput") -> Condition: +def show_completions_menu(python_input: PythonInput) -> Condition: return Condition( lambda: python_input.completion_visualisation == CompletionVisualisation.POP_UP ) -def show_multi_column_completions_menu(python_input: "PythonInput") -> Condition: +def show_multi_column_completions_menu(python_input: PythonInput) -> Condition: return Condition( lambda: python_input.completion_visualisation == CompletionVisualisation.MULTI_COLUMN ) -def python_sidebar(python_input: "PythonInput") -> Window: +def python_sidebar(python_input: PythonInput) -> Window: """ Create the `Layout` for the sidebar with the configurable options. """ @@ -98,11 +104,11 @@ def python_sidebar(python_input: "PythonInput") -> Window: def get_text_fragments() -> StyleAndTextTuples: tokens: StyleAndTextTuples = [] - def append_category(category: "OptionCategory") -> None: + def append_category(category: OptionCategory[Any]) -> None: tokens.extend( [ ("class:sidebar", " "), - ("class:sidebar.title", " %-36s" % category.title), + ("class:sidebar.title", f" {category.title:36}"), ("class:sidebar", "\n"), ] ) @@ -124,9 +130,9 @@ def goto_next(mouse_event: MouseEvent) -> None: sel = ",selected" if selected else "" tokens.append(("class:sidebar" + sel, " >" if selected else " ")) - tokens.append(("class:sidebar.label" + sel, "%-24s" % label, select_item)) + tokens.append(("class:sidebar.label" + sel, f"{label:24}", select_item)) tokens.append(("class:sidebar.status" + sel, " ", select_item)) - tokens.append(("class:sidebar.status" + sel, "%s" % status, goto_next)) + tokens.append(("class:sidebar.status" + sel, f"{status}", goto_next)) if selected: tokens.append(("[SetCursorPosition]", "")) @@ -142,7 +148,7 @@ def goto_next(mouse_event: MouseEvent) -> None: append_category(category) for option in category.options: - append(i, option.title, "%s" % option.get_current_value()) + append(i, option.title, str(option.get_current_value())) i += 1 tokens.pop() # Remove last newline. @@ -150,10 +156,10 @@ def goto_next(mouse_event: MouseEvent) -> None: return tokens class Control(FormattedTextControl): - def move_cursor_down(self): + def move_cursor_down(self) -> None: python_input.selected_option_index += 1 - def move_cursor_up(self): + def move_cursor_up(self) -> None: python_input.selected_option_index -= 1 return Window( @@ -165,12 +171,12 @@ def move_cursor_up(self): ) -def python_sidebar_navigation(python_input): +def python_sidebar_navigation(python_input: PythonInput) -> Window: """ Create the `Layout` showing the navigation information for the sidebar. """ - def get_text_fragments(): + def get_text_fragments() -> StyleAndTextTuples: # Show navigation info. return [ ("class:sidebar", " "), @@ -191,13 +197,13 @@ def get_text_fragments(): ) -def python_sidebar_help(python_input): +def python_sidebar_help(python_input: PythonInput) -> Container: """ Create the `Layout` for the help text for the current item in the sidebar. """ token = "class:sidebar.helptext" - def get_current_description(): + def get_current_description() -> str: """ Return the description of the selected option. """ @@ -209,7 +215,7 @@ def get_current_description(): i += 1 return "" - def get_help_text(): + def get_help_text() -> StyleAndTextTuples: return [(token, get_current_description())] return ConditionalContainer( @@ -225,7 +231,7 @@ def get_help_text(): ) -def signature_toolbar(python_input): +def signature_toolbar(python_input: PythonInput) -> Container: """ Return the `Layout` for the signature. """ @@ -293,13 +299,15 @@ def get_text_fragments() -> StyleAndTextTuples: content=Window( FormattedTextControl(get_text_fragments), height=Dimension.exact(1) ), - filter= # Show only when there is a signature - HasSignature(python_input) & + filter=HasSignature(python_input) + & # Signature needs to be shown. - ShowSignature(python_input) & + ShowSignature(python_input) + & # And no sidebar is visible. - ~ShowSidebar(python_input) & + ~ShowSidebar(python_input) + & # Not done yet. ~is_done, ) @@ -311,26 +319,28 @@ class PythonPromptMargin(PromptMargin): It shows something like "In [1]:". """ - def __init__(self, python_input) -> None: + def __init__(self, python_input: PythonInput) -> None: self.python_input = python_input - def get_prompt_style(): + def get_prompt_style() -> PromptStyle: return python_input.all_prompt_styles[python_input.prompt_style] def get_prompt() -> StyleAndTextTuples: return to_formatted_text(get_prompt_style().in_prompt()) - def get_continuation(width, line_number, is_soft_wrap): + def get_continuation( + width: int, line_number: int, is_soft_wrap: bool + ) -> StyleAndTextTuples: if python_input.show_line_numbers and not is_soft_wrap: - text = ("%i " % (line_number + 1)).rjust(width) + text = f"{line_number + 1} ".rjust(width) return [("class:line-number", text)] else: - return get_prompt_style().in2_prompt(width) + return to_formatted_text(get_prompt_style().in2_prompt(width)) super().__init__(get_prompt, get_continuation) -def status_bar(python_input: "PythonInput") -> Container: +def status_bar(python_input: PythonInput) -> Container: """ Create the `Layout` for the status bar. """ @@ -358,8 +368,7 @@ def get_text_fragments() -> StyleAndTextTuples: append( ( TB, - "%i/%i " - % (python_buffer.working_index + 1, len(python_buffer._working_lines)), + f"{python_buffer.working_index + 1}/{len(python_buffer._working_lines)} ", ) ) @@ -403,7 +412,7 @@ def get_text_fragments() -> StyleAndTextTuples: ) -def get_inputmode_fragments(python_input: "PythonInput") -> StyleAndTextTuples: +def get_inputmode_fragments(python_input: PythonInput) -> StyleAndTextTuples: """ Return current input mode as a list of (token, text) tuples for use in a toolbar. @@ -431,7 +440,7 @@ def toggle_vi_mode(mouse_event: MouseEvent) -> None: recording_register = app.vi_state.recording_register if recording_register: append((token, " ")) - append((token + " class:record", "RECORD({})".format(recording_register))) + append((token + " class:record", f"RECORD({recording_register})")) append((token, " - ")) if app.current_buffer.selection_state is not None: @@ -464,7 +473,7 @@ def toggle_vi_mode(mouse_event: MouseEvent) -> None: return result -def show_sidebar_button_info(python_input: "PythonInput") -> Container: +def show_sidebar_button_info(python_input: PythonInput) -> Container: """ Create `Layout` for the information in the right-bottom corner. (The right part of the status bar.) @@ -482,8 +491,7 @@ def toggle_sidebar(mouse_event: MouseEvent) -> None: ("class:status-toolbar", " - "), ( "class:status-toolbar.python-version", - "%s %i.%i.%i" - % (platform.python_implementation(), version[0], version[1], version[2]), + f"{platform.python_implementation()} {version[0]}.{version[1]}.{version[2]}", ), ("class:status-toolbar", " "), ] @@ -510,7 +518,7 @@ def get_text_fragments() -> StyleAndTextTuples: def create_exit_confirmation( - python_input: "PythonInput", style="class:exit-confirmation" + python_input: PythonInput, style: str = "class:exit-confirmation" ) -> Container: """ Create `Layout` for the exit message. @@ -519,7 +527,7 @@ def create_exit_confirmation( def get_text_fragments() -> StyleAndTextTuples: # Show "Do you really want to exit?" return [ - (style, "\n %s ([y]/n) " % python_input.exit_message), + (style, f"\n {python_input.exit_message} ([y]/n) "), ("[SetCursorPosition]", ""), (style, " \n"), ] @@ -534,7 +542,7 @@ def get_text_fragments() -> StyleAndTextTuples: ) -def meta_enter_message(python_input: "PythonInput") -> Container: +def meta_enter_message(python_input: PythonInput) -> Container: """ Create the `Layout` for the 'Meta+Enter` message. """ @@ -566,23 +574,23 @@ def extra_condition() -> bool: class PtPythonLayout: def __init__( self, - python_input: "PythonInput", - lexer=PythonLexer, - extra_body=None, - extra_toolbars=None, - extra_buffer_processors=None, - input_buffer_height: Optional[AnyDimension] = None, + python_input: PythonInput, + lexer: Lexer, + extra_body: AnyContainer | None = None, + extra_toolbars: list[AnyContainer] | None = None, + extra_buffer_processors: list[Processor] | None = None, + input_buffer_height: AnyDimension | None = None, ) -> None: D = Dimension - extra_body = [extra_body] if extra_body else [] + extra_body_list: list[AnyContainer] = [extra_body] if extra_body else [] extra_toolbars = extra_toolbars or [] - extra_buffer_processors = extra_buffer_processors or [] + input_buffer_height = input_buffer_height or D(min=6) search_toolbar = SearchToolbar(python_input.search_buffer) - def create_python_input_window(): - def menu_position(): + def create_python_input_window() -> Window: + def menu_position() -> int | None: """ When there is no autocompletion menu to be shown, and we have a signature, set the pop-up position at `bracket_start`. @@ -593,6 +601,7 @@ def menu_position(): row, col = python_input.signatures[0].bracket_start index = b.document.translate_row_col_to_index(row - 1, col) return index + return None return Window( BufferControl( @@ -622,7 +631,7 @@ def menu_position(): processor=AppendAutoSuggestion(), filter=~is_done ), ] - + extra_buffer_processors, + + (extra_buffer_processors or []), menu_position=menu_position, # Make sure that we always see the result of an reverse-i-search: preview_search=True, @@ -654,7 +663,7 @@ def menu_position(): [ FloatContainer( content=HSplit( - [create_python_input_window()] + extra_body + [create_python_input_window()] + extra_body_list ), floats=[ Float( @@ -685,7 +694,8 @@ def menu_position(): ), Float( left=2, - bottom=1, + top=2, + height=3, content=self.exit_confirmation, ), Float( diff --git a/ptpython/lexer.py b/src/ptpython/lexer.py similarity index 86% rename from ptpython/lexer.py rename to src/ptpython/lexer.py index 62e470f8..d925e95c 100644 --- a/ptpython/lexer.py +++ b/src/ptpython/lexer.py @@ -1,4 +1,6 @@ -from typing import Callable, Optional +from __future__ import annotations + +from typing import Callable from prompt_toolkit.document import Document from prompt_toolkit.formatted_text import StyleAndTextTuples @@ -17,7 +19,7 @@ class PtpythonLexer(Lexer): use a Python 3 lexer. """ - def __init__(self, python_lexer: Optional[Lexer] = None) -> None: + def __init__(self, python_lexer: Lexer | None = None) -> None: self.python_lexer = python_lexer or PygmentsLexer(PythonLexer) self.system_lexer = PygmentsLexer(BashLexer) diff --git a/src/ptpython/printer.py b/src/ptpython/printer.py new file mode 100644 index 00000000..a3578de7 --- /dev/null +++ b/src/ptpython/printer.py @@ -0,0 +1,439 @@ +from __future__ import annotations + +import traceback +from dataclasses import dataclass +from enum import Enum +from typing import Generator, Iterable + +from prompt_toolkit.formatted_text import ( + HTML, + AnyFormattedText, + FormattedText, + OneStyleAndTextTuple, + StyleAndTextTuples, + fragment_list_width, + merge_formatted_text, + to_formatted_text, +) +from prompt_toolkit.formatted_text.utils import split_lines +from prompt_toolkit.input import Input +from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent +from prompt_toolkit.output import Output +from prompt_toolkit.shortcuts import PromptSession, print_formatted_text +from prompt_toolkit.styles import BaseStyle, StyleTransformation +from prompt_toolkit.styles.pygments import pygments_token_to_classname +from prompt_toolkit.utils import get_cwidth +from pygments.lexers import PythonLexer, PythonTracebackLexer + +__all__ = ["OutputPrinter"] + +# Never reformat results larger than this: +MAX_REFORMAT_SIZE = 1_000_000 + + +@dataclass +class OutputPrinter: + """ + Result printer. + + Usage:: + + printer = OutputPrinter(...) + printer.display_result(...) + printer.display_exception(...) + """ + + output: Output + input: Input + style: BaseStyle + title: AnyFormattedText + style_transformation: StyleTransformation + + def display_result( + self, + result: object, + *, + out_prompt: AnyFormattedText, + reformat: bool, + highlight: bool, + paginate: bool, + ) -> None: + """ + Show __repr__ (or `__pt_repr__`) for an `eval` result and print to output. + + :param reformat: Reformat result using 'black' before printing if the + result is parsable as Python code. + :param highlight: Syntax highlight the result. + :param paginate: Show paginator when the result does not fit on the + screen. + """ + out_prompt = to_formatted_text(out_prompt) + out_prompt_width = fragment_list_width(out_prompt) + + result = self._insert_out_prompt_and_split_lines( + self._format_result_output( + result, + reformat=reformat, + highlight=highlight, + line_length=self.output.get_size().columns - out_prompt_width, + paginate=paginate, + ), + out_prompt=out_prompt, + ) + self._display_result(result, paginate=paginate) + + def display_exception( + self, e: BaseException, *, highlight: bool, paginate: bool + ) -> None: + """ + Render an exception. + """ + result = self._insert_out_prompt_and_split_lines( + self._format_exception_output(e, highlight=highlight), + out_prompt="", + ) + self._display_result(result, paginate=paginate) + + def display_style_and_text_tuples( + self, + result: Iterable[OneStyleAndTextTuple], + *, + paginate: bool, + ) -> None: + self._display_result( + self._insert_out_prompt_and_split_lines(result, out_prompt=""), + paginate=paginate, + ) + + def _display_result( + self, + lines: Iterable[StyleAndTextTuples], + *, + paginate: bool, + ) -> None: + if paginate: + self._print_paginated_formatted_text(lines) + else: + for line in lines: + self._print_formatted_text(line) + + self.output.flush() + + def _print_formatted_text(self, line: StyleAndTextTuples, end: str = "\n") -> None: + print_formatted_text( + FormattedText(line), + style=self.style, + style_transformation=self.style_transformation, + include_default_pygments_style=False, + output=self.output, + end=end, + ) + + def _format_result_output( + self, + result: object, + *, + reformat: bool, + highlight: bool, + line_length: int, + paginate: bool, + ) -> Generator[OneStyleAndTextTuple, None, None]: + """ + Format __repr__ for an `eval` result. + + Note: this can raise `KeyboardInterrupt` if either calling `__repr__`, + `__pt_repr__` or formatting the output with "Black" takes to long + and the user presses Control-C. + """ + # If __pt_repr__ is present, take this. This can return prompt_toolkit + # formatted text. + try: + if hasattr(result, "__pt_repr__"): + formatted_result_repr = to_formatted_text( + getattr(result, "__pt_repr__")() + ) + yield from formatted_result_repr + return + except (GeneratorExit, KeyboardInterrupt): + raise # Don't catch here. + except: + # For bad code, `__getattr__` can raise something that's not an + # `AttributeError`. This happens already when calling `hasattr()`. + pass + + # Call `__repr__` of given object first, to turn it in a string. + try: + result_repr = repr(result) + except KeyboardInterrupt: + raise # Don't catch here. + except BaseException as e: + # Calling repr failed. + self.display_exception(e, highlight=highlight, paginate=paginate) + return + + # Determine whether it's valid Python code. If not, + # reformatting/highlighting won't be applied. + if len(result_repr) < MAX_REFORMAT_SIZE: + try: + compile(result_repr, "", "eval") + except SyntaxError: + valid_python = False + else: + valid_python = True + else: + valid_python = False + + if valid_python and reformat: + # Inline import. Slightly speed up start-up time if black is + # not used. + try: + import black + + if not hasattr(black, "Mode"): + raise ImportError + except ImportError: + pass # no Black package in your installation + else: + result_repr = black.format_str( + result_repr, + mode=black.Mode(line_length=line_length), + ) + + if valid_python and highlight: + yield from _lex_python_result(result_repr) + else: + yield ("", result_repr) + + def _insert_out_prompt_and_split_lines( + self, result: Iterable[OneStyleAndTextTuple], out_prompt: AnyFormattedText + ) -> Iterable[StyleAndTextTuples]: + r""" + Split styled result in lines (based on the \n characters in the result) + an insert output prompt on whitespace in front of each line. (This does + not yet do the soft wrapping.) + + Yield lines as a result. + """ + out_prompt = to_formatted_text(out_prompt) + out_prompt_width = fragment_list_width(out_prompt) + prefix = ("", " " * out_prompt_width) + + for i, line in enumerate(split_lines(result)): + if i == 0: + line = [*out_prompt, *line] + else: + line = [prefix, *line] + yield line + + def _apply_soft_wrapping( + self, lines: Iterable[StyleAndTextTuples] + ) -> Iterable[StyleAndTextTuples]: + """ + Apply soft wrapping to the given lines. Wrap according to the terminal + width. Insert whitespace in front of each wrapped line to align it with + the output prompt. + """ + line_length = self.output.get_size().columns + + # Iterate over hard wrapped lines. + for lineno, line in enumerate(lines): + columns_in_buffer = 0 + current_line: list[OneStyleAndTextTuple] = [] + + for style, text, *_ in line: + for c in text: + width = get_cwidth(c) + + # (Soft) wrap line if it doesn't fit. + if columns_in_buffer + width > line_length: + yield current_line + columns_in_buffer = 0 + current_line = [] + + columns_in_buffer += width + current_line.append((style, c)) + + yield current_line + + def _print_paginated_formatted_text( + self, lines: Iterable[StyleAndTextTuples] + ) -> None: + """ + Print formatted text, using --MORE-- style pagination. + (Avoid filling up the terminal's scrollback buffer.) + """ + lines = self._apply_soft_wrapping(lines) + pager_prompt = create_pager_prompt( + self.style, self.title, output=self.output, input=self.input + ) + + abort = False + print_all = False + + # Max number of lines allowed in the buffer before painting. + size = self.output.get_size() + max_rows = size.rows - 1 + + # Page buffer. + page: StyleAndTextTuples = [] + + def show_pager() -> None: + nonlocal abort, max_rows, print_all + + # Run pager prompt in another thread. + # Same as for the input. This prevents issues with nested event + # loops. + pager_result = pager_prompt.prompt(in_thread=True) + + if pager_result == PagerResult.ABORT: + print("...") + abort = True + + elif pager_result == PagerResult.NEXT_LINE: + max_rows = 1 + + elif pager_result == PagerResult.NEXT_PAGE: + max_rows = size.rows - 1 + + elif pager_result == PagerResult.PRINT_ALL: + print_all = True + + # Loop over lines. Show --MORE-- prompt when page is filled. + rows = 0 + + for lineno, line in enumerate(lines): + page.extend(line) + page.append(("", "\n")) + rows += 1 + + if rows >= max_rows: + self._print_formatted_text(page, end="") + page = [] + rows = 0 + + if not print_all: + show_pager() + if abort: + return + + self._print_formatted_text(page) + + def _format_exception_output( + self, e: BaseException, highlight: bool + ) -> Generator[OneStyleAndTextTuple, None, None]: + if e.__cause__: + yield from self._format_exception_output(e.__cause__, highlight=highlight) + yield ( + "", + "\nThe above exception was the direct cause of the following exception:\n\n", + ) + elif e.__context__: + yield from self._format_exception_output(e.__context__, highlight=highlight) + yield ( + "", + "\nDuring handling of the above exception, another exception occurred:\n\n", + ) + + tblist = list(traceback.extract_tb(e.__traceback__)) + + for line_nr, tb_tuple in enumerate(tblist): + if tb_tuple[0] == "": + tblist = tblist[line_nr:] + break + + tb_list = traceback.format_list(tblist) + if tb_list: + tb_list.insert(0, "Traceback (most recent call last):\n") + tb_list.extend(traceback.format_exception_only(type(e), e)) + + tb_str = "".join(tb_list) + + # Format exception and write to output. + # (We use the default style. Most other styles result + # in unreadable colors for the traceback.) + if highlight: + for index, tokentype, text in PythonTracebackLexer().get_tokens_unprocessed( + tb_str + ): + yield ("class:" + pygments_token_to_classname(tokentype), text) + else: + yield ("", tb_str) + + +class PagerResult(Enum): + ABORT = "ABORT" + NEXT_LINE = "NEXT_LINE" + NEXT_PAGE = "NEXT_PAGE" + PRINT_ALL = "PRINT_ALL" + + +def create_pager_prompt( + style: BaseStyle, + title: AnyFormattedText = "", + input: Input | None = None, + output: Output | None = None, +) -> PromptSession[PagerResult]: + """ + Create a "--MORE--" prompt for paginated output. + """ + bindings = KeyBindings() + + @bindings.add("enter") + @bindings.add("down") + def next_line(event: KeyPressEvent) -> None: + event.app.exit(result=PagerResult.NEXT_LINE) + + @bindings.add("space") + def next_page(event: KeyPressEvent) -> None: + event.app.exit(result=PagerResult.NEXT_PAGE) + + @bindings.add("a") + def print_all(event: KeyPressEvent) -> None: + event.app.exit(result=PagerResult.PRINT_ALL) + + @bindings.add("q") + @bindings.add("c-c") + @bindings.add("c-d") + @bindings.add("escape", eager=True) + def no(event: KeyPressEvent) -> None: + event.app.exit(result=PagerResult.ABORT) + + @bindings.add("") + def _(event: KeyPressEvent) -> None: + "Disallow inserting other text." + pass + + session: PromptSession[PagerResult] = PromptSession( + merge_formatted_text( + [ + title, + HTML( + "" + " -- MORE -- " + "[Enter] Scroll " + "[Space] Next page " + "[a] Print all " + "[q] Quit " + ": " + ), + ] + ), + key_bindings=bindings, + erase_when_done=True, + style=style, + input=input, + output=output, + ) + return session + + +def _lex_python_result(result: str) -> Generator[tuple[str, str], None, None]: + "Return token list for Python string." + lexer = PythonLexer() + # Use `get_tokens_unprocessed`, so that we get exactly the same string, + # without line endings appended. `print_formatted_text` already appends a + # line ending, and otherwise we'll have two line endings. + tokens = lexer.get_tokens_unprocessed(result) + + for index, tokentype, text in tokens: + yield ("class:" + pygments_token_to_classname(tokentype), text) diff --git a/ptpython/prompt_style.py b/src/ptpython/prompt_style.py similarity index 87% rename from ptpython/prompt_style.py rename to src/ptpython/prompt_style.py index e7334af2..465c3dbe 100644 --- a/ptpython/prompt_style.py +++ b/src/ptpython/prompt_style.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from abc import ABCMeta, abstractmethod from typing import TYPE_CHECKING @@ -40,13 +42,13 @@ class IPythonPrompt(PromptStyle): A prompt resembling the IPython prompt. """ - def __init__(self, python_input: "PythonInput") -> None: + def __init__(self, python_input: PythonInput) -> None: self.python_input = python_input def in_prompt(self) -> AnyFormattedText: return [ ("class:in", "In ["), - ("class:in.number", "%s" % self.python_input.current_statement_index), + ("class:in.number", f"{self.python_input.current_statement_index}"), ("class:in", "]: "), ] @@ -56,7 +58,7 @@ def in2_prompt(self, width: int) -> AnyFormattedText: def out_prompt(self) -> AnyFormattedText: return [ ("class:out", "Out["), - ("class:out.number", "%s" % self.python_input.current_statement_index), + ("class:out.number", f"{self.python_input.current_statement_index}"), ("class:out", "]:"), ("", " "), ] diff --git a/ptpython/py.typed b/src/ptpython/py.typed similarity index 100% rename from ptpython/py.typed rename to src/ptpython/py.typed diff --git a/ptpython/python_input.py b/src/ptpython/python_input.py similarity index 86% rename from ptpython/python_input.py rename to src/ptpython/python_input.py index 1785f523..7e218eb9 100644 --- a/ptpython/python_input.py +++ b/src/ptpython/python_input.py @@ -2,11 +2,12 @@ Application for reading Python input. This can be used for creation of Python REPLs. """ -import __future__ -from asyncio import get_event_loop +from __future__ import annotations + +from asyncio import get_running_loop from functools import partial -from typing import TYPE_CHECKING, Any, Callable, Dict, Generic, List, Optional, TypeVar +from typing import TYPE_CHECKING, Any, Callable, Dict, Generic, Mapping, TypeVar, Union from prompt_toolkit.application import Application, get_app from prompt_toolkit.auto_suggest import ( @@ -23,9 +24,15 @@ ThreadedCompleter, merge_completers, ) +from prompt_toolkit.cursor_shapes import ( + AnyCursorShapeConfig, + CursorShape, + DynamicCursorShapeConfig, + ModalCursorShapeConfig, +) from prompt_toolkit.document import Document from prompt_toolkit.enums import DEFAULT_BUFFER, EditingMode -from prompt_toolkit.filters import Condition +from prompt_toolkit.filters import Condition, FilterOrBool from prompt_toolkit.formatted_text import AnyFormattedText from prompt_toolkit.history import ( FileHistory, @@ -43,7 +50,13 @@ from prompt_toolkit.key_binding.bindings.open_in_editor import ( load_open_in_editor_bindings, ) +from prompt_toolkit.key_binding.key_bindings import Binding, KeyHandlerCallable +from prompt_toolkit.key_binding.key_processor import KeyPressEvent from prompt_toolkit.key_binding.vi_state import InputMode +from prompt_toolkit.keys import Keys +from prompt_toolkit.layout.containers import AnyContainer +from prompt_toolkit.layout.dimension import AnyDimension +from prompt_toolkit.layout.processors import Processor from prompt_toolkit.lexers import DynamicLexer, Lexer, SimpleLexer from prompt_toolkit.output import ColorDepth, Output from prompt_toolkit.styles import ( @@ -72,6 +85,11 @@ from .utils import unindent_code from .validator import PythonValidator +# Isort introduces a SyntaxError, if we'd write `import __future__`. +# https://github.com/PyCQA/isort/issues/2100 +__future__ = __import__("__future__") + + __all__ = ["PythonInput"] @@ -79,22 +97,22 @@ from typing_extensions import Protocol class _SupportsLessThan(Protocol): - # Taken from typeshed. _T is used by "sorted", which needs anything + # Taken from typeshed. _T_lt is used by "sorted", which needs anything # sortable. - def __lt__(self, __other: Any) -> bool: - ... + def __lt__(self, __other: Any) -> bool: ... -_T = TypeVar("_T", bound="_SupportsLessThan") +_T_lt = TypeVar("_T_lt", bound="_SupportsLessThan") +_T_kh = TypeVar("_T_kh", bound=Union[KeyHandlerCallable, Binding]) -class OptionCategory: - def __init__(self, title: str, options: List["Option"]) -> None: +class OptionCategory(Generic[_T_lt]): + def __init__(self, title: str, options: list[Option[_T_lt]]) -> None: self.title = title self.options = options -class Option(Generic[_T]): +class Option(Generic[_T_lt]): """ Ptpython configuration option that can be shown and modified from the sidebar. @@ -110,10 +128,10 @@ def __init__( self, title: str, description: str, - get_current_value: Callable[[], _T], + get_current_value: Callable[[], _T_lt], # We accept `object` as return type for the select functions, because # often they return an unused boolean. Maybe this can be improved. - get_values: Callable[[], Dict[_T, Callable[[], object]]], + get_values: Callable[[], Mapping[_T_lt, Callable[[], object]]], ) -> None: self.title = title self.description = description @@ -121,7 +139,7 @@ def __init__( self.get_values = get_values @property - def values(self) -> Dict[_T, Callable[[], object]]: + def values(self) -> Mapping[_T_lt, Callable[[], object]]: return self.get_values() def activate_next(self, _previous: bool = False) -> None: @@ -182,26 +200,25 @@ class PythonInput: def __init__( self, - get_globals: Optional[_GetNamespace] = None, - get_locals: Optional[_GetNamespace] = None, - history_filename: Optional[str] = None, + get_globals: _GetNamespace | None = None, + get_locals: _GetNamespace | None = None, + history_filename: str | None = None, vi_mode: bool = False, - color_depth: Optional[ColorDepth] = None, + color_depth: ColorDepth | None = None, # Input/output. - input: Optional[Input] = None, - output: Optional[Output] = None, + input: Input | None = None, + output: Output | None = None, # For internal use. - extra_key_bindings: Optional[KeyBindings] = None, - create_app=True, - _completer: Optional[Completer] = None, - _validator: Optional[Validator] = None, - _lexer: Optional[Lexer] = None, - _extra_buffer_processors=None, - _extra_layout_body=None, - _extra_toolbars=None, - _input_buffer_height=None, + extra_key_bindings: KeyBindings | None = None, + create_app: bool = True, + _completer: Completer | None = None, + _validator: Validator | None = None, + _lexer: Lexer | None = None, + _extra_buffer_processors: list[Processor] | None = None, + _extra_layout_body: AnyContainer | None = None, + _extra_toolbars: list[AnyContainer] | None = None, + _input_buffer_height: AnyDimension | None = None, ) -> None: - self.get_globals: _GetNamespace = get_globals or (lambda: {}) self.get_locals: _GetNamespace = get_locals or self.get_globals @@ -239,7 +256,7 @@ def __init__( self.history = InMemoryHistory() self._input_buffer_height = _input_buffer_height - self._extra_layout_body = _extra_layout_body or [] + self._extra_layout_body = _extra_layout_body self._extra_toolbars = _extra_toolbars or [] self._extra_buffer_processors = _extra_buffer_processors or [] @@ -298,7 +315,7 @@ def __init__( self.show_exit_confirmation: bool = False # The title to be displayed in the terminal. (None or string.) - self.terminal_title: Optional[str] = None + self.terminal_title: str | None = None self.exit_message: str = "Do you really want to exit?" self.insert_blank_line_after_output: bool = True # (For the REPL.) @@ -309,27 +326,31 @@ def __init__( self.search_buffer: Buffer = Buffer() self.docstring_buffer: Buffer = Buffer(read_only=True) + # Cursor shapes. + self.cursor_shape_config = "Block" + self.all_cursor_shape_configs: dict[str, AnyCursorShapeConfig] = { + "Block": CursorShape.BLOCK, + "Underline": CursorShape.UNDERLINE, + "Beam": CursorShape.BEAM, + "Modal (vi)": ModalCursorShapeConfig(), + "Blink block": CursorShape.BLINKING_BLOCK, + "Blink under": CursorShape.BLINKING_UNDERLINE, + "Blink beam": CursorShape.BLINKING_BEAM, + } + # Tokens to be shown at the prompt. self.prompt_style: str = "classic" # The currently active style. # Styles selectable from the menu. - self.all_prompt_styles: Dict[str, PromptStyle] = { + self.all_prompt_styles: dict[str, PromptStyle] = { "ipython": IPythonPrompt(self), "classic": ClassicPrompt(), } - self.get_input_prompt = lambda: self.all_prompt_styles[ - self.prompt_style - ].in_prompt() - - self.get_output_prompt = lambda: self.all_prompt_styles[ - self.prompt_style - ].out_prompt() - #: Load styles. - self.code_styles: Dict[str, BaseStyle] = get_all_code_styles() + self.code_styles: dict[str, BaseStyle] = get_all_code_styles() self.ui_styles = get_all_ui_styles() - self._current_code_style_name: str = "default" + self._current_code_style_name: str = "default-ansi" self._current_ui_style_name: str = "default" if is_windows(): @@ -345,11 +366,11 @@ def __init__( self.options = self._create_options() self.selected_option_index: int = 0 - #: Incremeting integer counting the current statement. + #: Incrementing integer counting the current statement. self.current_statement_index: int = 1 # Code signatures. (This is set asynchronously after a timeout.) - self.signatures: List[Signature] = [] + self.signatures: list[Signature] = [] # Boolean indicating whether we have a signatures thread running. # (Never run more than one at the same time.) @@ -388,7 +409,7 @@ def __init__( # Create an app if requested. If not, the global get_app() is returned # for self.app via property getter. if create_app: - self._app: Optional[Application] = self._create_application(input, output) + self._app: Application[str] | None = self._create_application(input, output) # Setting vi_mode will not work unless the prompt_toolkit # application has been created. if vi_mode: @@ -396,6 +417,12 @@ def __init__( else: self._app = None + def get_input_prompt(self) -> AnyFormattedText: + return self.all_prompt_styles[self.prompt_style].in_prompt() + + def get_output_prompt(self) -> AnyFormattedText: + return self.all_prompt_styles[self.prompt_style].out_prompt() + def _accept_handler(self, buff: Buffer) -> bool: app = get_app() app.exit(result=buff.text) @@ -408,7 +435,7 @@ def option_count(self) -> int: return sum(len(category.options) for category in self.options) @property - def selected_option(self) -> Option: + def selected_option(self) -> Option[Any]: "Return the currently selected option." i = 0 for category in self.options: @@ -443,24 +470,36 @@ def get_compiler_flags(self) -> int: return flags - @property - def add_key_binding(self) -> Callable[[_T], _T]: + def add_key_binding( + self, + *keys: Keys | str, + filter: FilterOrBool = True, + eager: FilterOrBool = False, + is_global: FilterOrBool = False, + save_before: Callable[[KeyPressEvent], bool] = (lambda e: True), + record_in_macro: FilterOrBool = True, + ) -> Callable[[_T_kh], _T_kh]: """ Shortcut for adding new key bindings. (Mostly useful for a config.py file, that receives a PythonInput/Repl instance as input.) + All arguments are identical to prompt_toolkit's `KeyBindings.add`. + :: @python_input.add_key_binding(Keys.ControlX, filter=...) def handler(event): ... """ - - def add_binding_decorator(*k, **kw): - return self.extra_key_bindings.add(*k, **kw) - - return add_binding_decorator + return self.extra_key_bindings.add( + *keys, + filter=filter, + eager=eager, + is_global=is_global, + save_before=save_before, + record_in_macro=record_in_macro, + ) def install_code_colorscheme(self, name: str, style: BaseStyle) -> None: """ @@ -514,7 +553,7 @@ def _generate_style(self) -> BaseStyle: self.ui_styles[self._current_ui_style_name], ) - def _create_options(self) -> List[OptionCategory]: + def _create_options(self) -> list[OptionCategory[Any]]: """ Create a list of `Option` instances for the options sidebar. """ @@ -530,15 +569,17 @@ def disable(attribute: str) -> bool: return True def simple_option( - title: str, description: str, field_name: str, values: Optional[List] = None - ) -> Option: + title: str, + description: str, + field_name: str, + values: tuple[str, str] = ("off", "on"), + ) -> Option[str]: "Create Simple on/of option." - values = values or ["off", "on"] - def get_current_value(): + def get_current_value() -> str: return values[bool(getattr(self, field_name))] - def get_values(): + def get_values() -> dict[str, Callable[[], bool]]: return { values[1]: lambda: enable(field_name), values[0]: lambda: disable(field_name), @@ -566,6 +607,16 @@ def get_values(): "Vi": lambda: enable("vi_mode"), }, ), + Option( + title="Cursor shape", + description="Change the cursor style, possibly according " + "to the Vi input mode.", + get_current_value=lambda: self.cursor_shape_config, + get_values=lambda: { + s: partial(enable, "cursor_shape_config", s) + for s in self.all_cursor_shape_configs + }, + ), simple_option( title="Paste mode", description="When enabled, don't indent automatically.", @@ -715,10 +766,10 @@ def get_values(): title="Prompt", description="Visualisation of the prompt. ('>>>' or 'In [1]:')", get_current_value=lambda: self.prompt_style, - get_values=lambda: dict( - (s, partial(enable, "prompt_style", s)) + get_values=lambda: { + s: partial(enable, "prompt_style", s) for s in self.all_prompt_styles - ), + }, ), simple_option( title="Blank line after input", @@ -789,7 +840,7 @@ def get_values(): [ simple_option( title="Syntax highlighting", - description="Use colors for syntax highligthing", + description="Use colors for syntax highlighting", field_name="enable_syntax_highlighting", ), simple_option( @@ -810,10 +861,10 @@ def get_values(): title="User interface", description="Color scheme to use for the user interface.", get_current_value=lambda: self._current_ui_style_name, - get_values=lambda: dict( - (name, partial(self.use_ui_colorscheme, name)) + get_values=lambda: { + name: partial(self.use_ui_colorscheme, name) for name in self.ui_styles - ), + }, ), Option( title="Color depth", @@ -827,18 +878,18 @@ def get_values(): Option( title="Min brightness", description="Minimum brightness for the color scheme (default=0.0).", - get_current_value=lambda: "%.2f" % self.min_brightness, + get_current_value=lambda: f"{self.min_brightness:.2f}", get_values=lambda: { - "%.2f" % value: partial(self._set_min_brightness, value) + f"{value:.2f}": partial(self._set_min_brightness, value) for value in brightness_values }, ), Option( title="Max brightness", description="Maximum brightness for the color scheme (default=1.0).", - get_current_value=lambda: "%.2f" % self.max_brightness, + get_current_value=lambda: f"{self.max_brightness:.2f}", get_values=lambda: { - "%.2f" % value: partial(self._set_max_brightness, value) + f"{value:.2f}": partial(self._set_max_brightness, value) for value in brightness_values }, ), @@ -847,8 +898,8 @@ def get_values(): ] def _create_application( - self, input: Optional[Input], output: Optional[Output] - ) -> Application: + self, input: Input | None, output: Output | None + ) -> Application[str]: """ Create an `Application` instance. """ @@ -878,6 +929,9 @@ def _create_application( style_transformation=self.style_transformation, include_default_pygments_style=False, reverse_vi_search_direction=True, + cursor=DynamicCursorShapeConfig( + lambda: self.all_cursor_shape_configs[self.cursor_shape_config] + ), input=input, output=output, ) @@ -926,7 +980,7 @@ def vi_mode(self, value: bool) -> None: self.editing_mode = EditingMode.EMACS @property - def app(self) -> Application: + def app(self) -> Application[str]: if self._app is None: return get_app() return self._app @@ -937,7 +991,7 @@ def _on_input_timeout(self, buff: Buffer) -> None: in another thread, get the signature of the current code. """ - def get_signatures_in_executor(document: Document) -> List[Signature]: + def get_signatures_in_executor(document: Document) -> list[Signature]: # First, get signatures from Jedi. If we didn't found any and if # "dictionary completion" (eval-based completion) is enabled, then # get signatures using eval. @@ -954,7 +1008,7 @@ def get_signatures_in_executor(document: Document) -> List[Signature]: app = self.app async def on_timeout_task() -> None: - loop = get_event_loop() + loop = get_running_loop() # Never run multiple get-signature threads. if self._get_signatures_thread_running: @@ -1027,6 +1081,7 @@ def read(self) -> str: This can raise EOFError, when Control-D is pressed. """ + # Capture the current input_mode in order to restore it after reset, # for ViState.reset() sets it to InputMode.INSERT unconditionally and # doesn't accept any arguments. @@ -1061,4 +1116,5 @@ def pre_run( return result except KeyboardInterrupt: # Abort - try again. + self.signatures = [] self.default_buffer.document = Document() diff --git a/src/ptpython/repl.py b/src/ptpython/repl.py new file mode 100644 index 00000000..90772689 --- /dev/null +++ b/src/ptpython/repl.py @@ -0,0 +1,622 @@ +""" +Utility for creating a Python repl. + +:: + + from ptpython.repl import embed + embed(globals(), locals(), vi_mode=False) + +""" + +from __future__ import annotations + +import asyncio +import builtins +import os +import signal +import sys +import traceback +import types +import warnings +from dis import COMPILER_FLAG_NAMES +from pathlib import Path +from typing import ( + Any, + Callable, + ContextManager, + Coroutine, + Iterable, + Literal, + NoReturn, + Sequence, + overload, +) + +from prompt_toolkit.formatted_text import OneStyleAndTextTuple +from prompt_toolkit.patch_stdout import patch_stdout as patch_stdout_context +from prompt_toolkit.shortcuts import ( + clear_title, + set_title, +) +from prompt_toolkit.utils import DummyContext +from pygments.lexers import PythonTracebackLexer # noqa: F401 + +from .printer import OutputPrinter +from .python_input import PythonInput + +PyCF_ALLOW_TOP_LEVEL_AWAIT: int +try: + from ast import PyCF_ALLOW_TOP_LEVEL_AWAIT # type: ignore +except ImportError: + PyCF_ALLOW_TOP_LEVEL_AWAIT = 0 + + +__all__ = [ + "PythonRepl", + "enable_deprecation_warnings", + "run_config", + "embed", + "exit", + "ReplExit", +] + + +def _get_coroutine_flag() -> int | None: + for k, v in COMPILER_FLAG_NAMES.items(): + if v == "COROUTINE": + return k + + # Flag not found. + return None + + +COROUTINE_FLAG: int | None = _get_coroutine_flag() + + +def _has_coroutine_flag(code: types.CodeType) -> bool: + if COROUTINE_FLAG is None: + # Not supported on this Python version. + return False + + return bool(code.co_flags & COROUTINE_FLAG) + + +class PythonRepl(PythonInput): + def __init__(self, *a, **kw) -> None: + self._startup_paths: Sequence[str | Path] | None = kw.pop("startup_paths", None) + super().__init__(*a, **kw) + self._load_start_paths() + + def _load_start_paths(self) -> None: + "Start the Read-Eval-Print Loop." + if self._startup_paths: + for path in self._startup_paths: + if os.path.exists(path): + with open(path, "rb") as f: + code = compile(f.read(), path, "exec") + exec(code, self.get_globals(), self.get_locals()) + else: + output = self.app.output + output.write(f"WARNING | File not found: {path}\n\n") + + def run_and_show_expression(self, expression: str) -> None: + try: + # Eval. + try: + result = self.eval(expression) + except KeyboardInterrupt: + # KeyboardInterrupt doesn't inherit from Exception. + raise + except SystemExit: + raise + except ReplExit: + raise + except BaseException as e: + self._handle_exception(e) + else: + if isinstance(result, exit): + # When `exit` is evaluated without parentheses. + # Automatically trigger the `ReplExit` exception. + raise ReplExit + + # Print. + if result is not None: + self._show_result(result) + if self.insert_blank_line_after_output: + self.app.output.write("\n") + + # Loop. + self.current_statement_index += 1 + self.signatures = [] + + except KeyboardInterrupt as e: + # Handle all possible `KeyboardInterrupt` errors. This can + # happen during the `eval`, but also during the + # `show_result` if something takes too long. + # (Try/catch is around the whole block, because we want to + # prevent that a Control-C keypress terminates the REPL in + # any case.) + self._handle_keyboard_interrupt(e) + + def _get_output_printer(self) -> OutputPrinter: + return OutputPrinter( + output=self.app.output, + input=self.app.input, + style=self._current_style, + style_transformation=self.style_transformation, + title=self.title, + ) + + def _show_result(self, result: object) -> None: + self._get_output_printer().display_result( + result=result, + out_prompt=self.get_output_prompt(), + reformat=self.enable_output_formatting, + highlight=self.enable_syntax_highlighting, + paginate=self.enable_pager, + ) + + def run(self) -> None: + """ + Run the REPL loop. + """ + if self.terminal_title: + set_title(self.terminal_title) + + self._add_to_namespace() + + try: + while True: + # Pull text from the user. + try: + text = self.read() + except EOFError: + return + except BaseException: + # Something went wrong while reading input. + # (E.g., a bug in the completer that propagates. Don't + # crash the REPL.) + traceback.print_exc() + continue + + # Run it; display the result (or errors if applicable). + try: + self.run_and_show_expression(text) + except ReplExit: + return + finally: + if self.terminal_title: + clear_title() + self._remove_from_namespace() + + async def run_and_show_expression_async(self, text: str) -> Any: + loop = asyncio.get_running_loop() + system_exit: SystemExit | None = None + + try: + try: + # Create `eval` task. Ensure that control-c will cancel this + # task. + async def eval() -> Any: + nonlocal system_exit + try: + return await self.eval_async(text) + except SystemExit as e: + # Don't propagate SystemExit in `create_task()`. That + # will kill the event loop. We want to handle it + # gracefully. + system_exit = e + + task = asyncio.create_task(eval()) + loop.add_signal_handler(signal.SIGINT, lambda *_: task.cancel()) + result = await task + + if system_exit is not None: + raise system_exit + except KeyboardInterrupt: + # KeyboardInterrupt doesn't inherit from Exception. + raise + except SystemExit: + raise + except BaseException as e: + self._handle_exception(e) + else: + # Print. + if result is not None: + await loop.run_in_executor(None, lambda: self._show_result(result)) + + # Loop. + self.current_statement_index += 1 + self.signatures = [] + # Return the result for future consumers. + return result + finally: + loop.remove_signal_handler(signal.SIGINT) + + except KeyboardInterrupt as e: + # Handle all possible `KeyboardInterrupt` errors. This can + # happen during the `eval`, but also during the + # `show_result` if something takes too long. + # (Try/catch is around the whole block, because we want to + # prevent that a Control-C keypress terminates the REPL in + # any case.) + self._handle_keyboard_interrupt(e) + + async def run_async(self) -> None: + """ + Run the REPL loop, but run the blocking parts in an executor, so that + we don't block the event loop. Both the input and output (which can + display a pager) will run in a separate thread with their own event + loop, this way ptpython's own event loop won't interfere with the + asyncio event loop from where this is called. + + The "eval" however happens in the current thread, which is important. + (Both for control-C to work, as well as for the code to see the right + thread in which it was embedded). + """ + loop = asyncio.get_running_loop() + + if self.terminal_title: + set_title(self.terminal_title) + + self._add_to_namespace() + + try: + while True: + try: + # Read. + try: + text = await loop.run_in_executor(None, self.read) + except EOFError: + return + except BaseException: + # Something went wrong while reading input. + # (E.g., a bug in the completer that propagates. Don't + # crash the REPL.) + traceback.print_exc() + continue + + # Eval. + await self.run_and_show_expression_async(text) + + except KeyboardInterrupt as e: + # XXX: This does not yet work properly. In some situations, + # `KeyboardInterrupt` exceptions can end up in the event + # loop selector. + self._handle_keyboard_interrupt(e) + except SystemExit: + return + finally: + if self.terminal_title: + clear_title() + self._remove_from_namespace() + + def eval(self, line: str) -> object: + """ + Evaluate the line and print the result. + """ + # WORKAROUND: Due to a bug in Jedi, the current directory is removed + # from sys.path. See: https://github.com/davidhalter/jedi/issues/1148 + if "" not in sys.path: + sys.path.insert(0, "") + + if line.lstrip().startswith("!"): + # Run as shell command + os.system(line[1:]) + else: + # Try eval first + try: + code = self._compile_with_flags(line, "eval") + except SyntaxError: + pass + else: + # No syntax errors for eval. Do eval. + result = eval(code, self.get_globals(), self.get_locals()) + + if _has_coroutine_flag(code): + result = asyncio.get_running_loop().run_until_complete(result) + + self._store_eval_result(result) + return result + + # If not a valid `eval` expression, run using `exec` instead. + # Note that we shouldn't run this in the `except SyntaxError` block + # above, then `sys.exc_info()` would not report the right error. + # See issue: https://github.com/prompt-toolkit/ptpython/issues/435 + code = self._compile_with_flags(line, "exec") + result = eval(code, self.get_globals(), self.get_locals()) + + if _has_coroutine_flag(code): + result = asyncio.get_running_loop().run_until_complete(result) + + return None + + async def eval_async(self, line: str) -> object: + """ + Evaluate the line and print the result. + """ + # WORKAROUND: Due to a bug in Jedi, the current directory is removed + # from sys.path. See: https://github.com/davidhalter/jedi/issues/1148 + if "" not in sys.path: + sys.path.insert(0, "") + + if line.lstrip().startswith("!"): + # Run as shell command + os.system(line[1:]) + else: + # Try eval first + try: + code = self._compile_with_flags(line, "eval") + except SyntaxError: + pass + else: + # No syntax errors for eval. Do eval. + result = eval(code, self.get_globals(), self.get_locals()) + + if _has_coroutine_flag(code): + result = await result + + self._store_eval_result(result) + return result + + # If not a valid `eval` expression, compile as `exec` expression + # but still run with eval to get an awaitable in case of a + # awaitable expression. + code = self._compile_with_flags(line, "exec") + result = eval(code, self.get_globals(), self.get_locals()) + + if _has_coroutine_flag(code): + result = await result + + return None + + def _store_eval_result(self, result: object) -> None: + locals: dict[str, Any] = self.get_locals() + locals["_"] = locals[f"_{self.current_statement_index}"] = result + + def get_compiler_flags(self) -> int: + return super().get_compiler_flags() | PyCF_ALLOW_TOP_LEVEL_AWAIT + + def _compile_with_flags(self, code: str, mode: str) -> Any: + "Compile code with the right compiler flags." + return compile( + code, + "", + mode, + flags=self.get_compiler_flags(), + dont_inherit=True, + ) + + def _handle_exception(self, e: BaseException) -> None: + # Required for pdb.post_mortem() to work. + t, v, tb = sys.exc_info() + sys.last_type, sys.last_value, sys.last_traceback = t, v, tb + + self._get_output_printer().display_exception( + e, + highlight=self.enable_syntax_highlighting, + paginate=self.enable_pager, + ) + + def _handle_keyboard_interrupt(self, e: KeyboardInterrupt) -> None: + output = self.app.output + + output.write("\rKeyboardInterrupt\n\n") + output.flush() + + def _add_to_namespace(self) -> None: + """ + Add ptpython built-ins to global namespace. + """ + globals = self.get_globals() + + # Add a 'get_ptpython', similar to 'get_ipython' + def get_ptpython() -> PythonInput: + return self + + globals["get_ptpython"] = get_ptpython + globals["exit"] = exit() + + def _remove_from_namespace(self) -> None: + """ + Remove added symbols from the globals. + """ + globals = self.get_globals() + del globals["get_ptpython"] + + def print_paginated_formatted_text( + self, + formatted_text: Iterable[OneStyleAndTextTuple], + end: str = "\n", + ) -> None: + # Warning: This is mainly here backwards-compatibility. Some projects + # call `print_paginated_formatted_text` on the Repl object. + self._get_output_printer().display_style_and_text_tuples( + formatted_text, paginate=True + ) + + +def enable_deprecation_warnings() -> None: + """ + Show deprecation warnings, when they are triggered directly by actions in + the REPL. This is recommended to call, before calling `embed`. + + e.g. This will show an error message when the user imports the 'sha' + library on Python 2.7. + """ + warnings.filterwarnings("default", category=DeprecationWarning, module="__main__") + + +DEFAULT_CONFIG_FILE = "~/.config/ptpython/config.py" + + +def run_config(repl: PythonInput, config_file: str | None = None) -> None: + """ + Execute REPL config file. + + :param repl: `PythonInput` instance. + :param config_file: Path of the configuration file. + """ + explicit_config_file = config_file is not None + + # Expand tildes. + config_file = os.path.expanduser( + config_file if config_file is not None else DEFAULT_CONFIG_FILE + ) + + def enter_to_continue() -> None: + input("\nPress ENTER to continue...") + + # Check whether this file exists. + if not os.path.exists(config_file): + if explicit_config_file: + print(f"Impossible to read {config_file}") + enter_to_continue() + return + + # Run the config file in an empty namespace. + try: + namespace: dict[str, Any] = {} + + with open(config_file, "rb") as f: + code = compile(f.read(), config_file, "exec") + exec(code, namespace, namespace) + + # Now we should have a 'configure' method in this namespace. We call this + # method with the repl as an argument. + if "configure" in namespace: + namespace["configure"](repl) + + except Exception: + traceback.print_exc() + enter_to_continue() + + +class exit: + """ + Exit the ptpython REPL. + """ + + # This custom exit function ensures that the `embed` function returns from + # where we are embedded, and Python doesn't close `sys.stdin` like + # the default `exit` from `_sitebuiltins.Quitter` does. + + def __call__(self) -> NoReturn: + raise ReplExit + + def __repr__(self) -> str: + # (Same message as the built-in Python REPL.) + return "Use exit() or Ctrl-D (i.e. EOF) to exit" + + +class ReplExit(Exception): + """ + Exception raised by ptpython's exit function. + """ + + +@overload +def embed( + globals: dict[str, Any] | None = ..., + locals: dict[str, Any] | None = ..., + configure: Callable[[PythonRepl], None] | None = ..., + vi_mode: bool = ..., + history_filename: str | None = ..., + title: str | None = ..., + startup_paths: Sequence[str | Path] | None = ..., + patch_stdout: bool = ..., + patch_stdout_raw: bool = ..., + return_asyncio_coroutine: Literal[False] = ..., +) -> None: ... + + +@overload +def embed( + globals: dict[str, Any] | None = ..., + locals: dict[str, Any] | None = ..., + configure: Callable[[PythonRepl], None] | None = ..., + vi_mode: bool = ..., + history_filename: str | None = ..., + title: str | None = ..., + startup_paths: Sequence[str | Path] | None = ..., + patch_stdout: bool = ..., + patch_stdout_raw: bool = ..., + return_asyncio_coroutine: Literal[True] = ..., +) -> Coroutine[Any, Any, None]: ... + + +def embed( + globals: dict[str, Any] | None = None, + locals: dict[str, Any] | None = None, + configure: Callable[[PythonRepl], None] | None = None, + vi_mode: bool = False, + history_filename: str | None = None, + title: str | None = None, + startup_paths: Sequence[str | Path] | None = None, + patch_stdout: bool = False, + patch_stdout_raw: bool = False, + return_asyncio_coroutine: bool = False, +) -> None | Coroutine[Any, Any, None]: + """ + Call this to embed Python shell at the current point in your program. + It's similar to `IPython.embed` and `bpython.embed`. :: + + from prompt_toolkit.contrib.repl import embed + embed(globals(), locals()) + + :param vi_mode: Boolean. Use Vi instead of Emacs key bindings. + :param configure: Callable that will be called with the `PythonRepl` as a first + argument, to trigger configuration. + :param title: Title to be displayed in the terminal titlebar. (None or string.) + :param patch_stdout: When true, patch `sys.stdout` so that background + threads that are printing will print nicely above the prompt. + :param patch_stdout_raw: When true, patch_stdout will not escape/remove vt100 terminal escape sequences. + """ + # Default globals/locals + if globals is None: + globals = { + "__name__": "__main__", + "__package__": None, + "__doc__": None, + "__builtins__": builtins, + } + + locals = locals or globals + + def get_globals() -> dict[str, Any]: + return globals + + def get_locals() -> dict[str, Any]: + return locals + + # Create REPL. + repl = PythonRepl( + get_globals=get_globals, + get_locals=get_locals, + vi_mode=vi_mode, + history_filename=history_filename, + startup_paths=startup_paths, + ) + + if title: + repl.terminal_title = title + + if configure: + configure(repl) + + # Start repl. + patch_context: ContextManager[None] = ( + patch_stdout_context(raw=patch_stdout_raw) if patch_stdout else DummyContext() + ) + + if return_asyncio_coroutine: + + async def coroutine() -> None: + with patch_context: + await repl.run_async() + + return coroutine() # type: ignore + else: + with patch_context: + repl.run() + return None diff --git a/ptpython/signatures.py b/src/ptpython/signatures.py similarity index 93% rename from ptpython/signatures.py rename to src/ptpython/signatures.py index 228b99b2..b3e5c914 100644 --- a/ptpython/signatures.py +++ b/src/ptpython/signatures.py @@ -5,16 +5,22 @@ Either with the Jedi library, or using `inspect.signature` if Jedi fails and we can use `eval()` to evaluate the function object. """ + +from __future__ import annotations + import inspect from inspect import Signature as InspectSignature from inspect import _ParameterKind as ParameterKind -from typing import Any, Dict, List, Optional, Sequence, Tuple +from typing import TYPE_CHECKING, Any, Sequence from prompt_toolkit.document import Document from .completer import DictionaryCompleter from .utils import get_jedi_script_from_document +if TYPE_CHECKING: + import jedi.api.classes + __all__ = ["Signature", "get_signatures_using_jedi", "get_signatures_using_eval"] @@ -22,8 +28,8 @@ class Parameter: def __init__( self, name: str, - annotation: Optional[str], - default: Optional[str], + annotation: str | None, + default: str | None, kind: ParameterKind, ) -> None: self.name = name @@ -63,9 +69,9 @@ def __init__( name: str, docstring: str, parameters: Sequence[Parameter], - index: Optional[int] = None, + index: int | None = None, returns: str = "", - bracket_start: Tuple[int, int] = (0, 0), + bracket_start: tuple[int, int] = (0, 0), ) -> None: self.name = name self.docstring = docstring @@ -81,7 +87,7 @@ def from_inspect_signature( docstring: str, signature: InspectSignature, index: int, - ) -> "Signature": + ) -> Signature: parameters = [] def get_annotation_name(annotation: object) -> str: @@ -120,7 +126,7 @@ def get_annotation_name(annotation: object) -> str: ) @classmethod - def from_jedi_signature(cls, signature) -> "Signature": + def from_jedi_signature(cls, signature: jedi.api.classes.Signature) -> Signature: parameters = [] for p in signature.params: @@ -155,8 +161,8 @@ def __repr__(self) -> str: def get_signatures_using_jedi( - document: Document, locals: Dict[str, Any], globals: Dict[str, Any] -) -> List[Signature]: + document: Document, locals: dict[str, Any], globals: dict[str, Any] +) -> list[Signature]: script = get_jedi_script_from_document(document, locals, globals) # Show signatures in help text. @@ -190,15 +196,14 @@ def get_signatures_using_jedi( def get_signatures_using_eval( - document: Document, locals: Dict[str, Any], globals: Dict[str, Any] -) -> List[Signature]: + document: Document, locals: dict[str, Any], globals: dict[str, Any] +) -> list[Signature]: """ Look for the signature of the function before the cursor position without use of Jedi. This uses a similar approach as the `DictionaryCompleter` of running `eval()` over the detected function name. """ # Look for open parenthesis, before cursor position. - text = document.text_before_cursor pos = document.cursor_position - 1 paren_mapping = {")": "(", "}": "{", "]": "["} diff --git a/ptpython/style.py b/src/ptpython/style.py similarity index 62% rename from ptpython/style.py rename to src/ptpython/style.py index 4b54d0cd..8242df31 100644 --- a/ptpython/style.py +++ b/src/ptpython/style.py @@ -1,4 +1,4 @@ -from typing import Dict +from __future__ import annotations from prompt_toolkit.styles import BaseStyle, Style, merge_styles from prompt_toolkit.styles.pygments import style_from_pygments_cls @@ -8,19 +8,20 @@ __all__ = ["get_all_code_styles", "get_all_ui_styles", "generate_style"] -def get_all_code_styles() -> Dict[str, BaseStyle]: +def get_all_code_styles() -> dict[str, BaseStyle]: """ Return a mapping from style names to their classes. """ - result: Dict[str, BaseStyle] = { + result: dict[str, BaseStyle] = { name: style_from_pygments_cls(get_style_by_name(name)) for name in get_all_styles() } result["win32"] = Style.from_dict(win32_code_style) + result["default-ansi"] = Style.from_dict(default_ansi_code_style) return result -def get_all_ui_styles() -> Dict[str, BaseStyle]: +def get_all_ui_styles() -> dict[str, BaseStyle]: """ Return a dict mapping {ui_style_name -> style_dict}. """ @@ -38,6 +39,63 @@ def generate_style(python_style: BaseStyle, ui_style: BaseStyle) -> BaseStyle: return merge_styles([python_style, ui_style]) +# Use ANSI colors for the default theme. +# This is `DefaultStyle` from Pygments, modified to use ANSI colors instead of +# RGB. This adapts better to light/dark mode, because the built-in themes from +# a terminal are typically designed for whatever background is used. All the +# other Pygments themes use RGB, which is fine, because the user consciously +# chooses what works for them. + +# To convert, do: +# from prompt_toolkit.output import ColorDepth +# from prompt_toolkit.output.vt100 import _EscapeCodeCache, _get_closest_ansi_color +# print(_get_closest_ansi_color( +# *_EscapeCodeCache(ColorDepth.DEPTH_8_BIT)._color_name_to_rgb('bbbbbb')) +# ) + +default_ansi_code_style = { + "pygments.whitespace": "ansigray", # "#bbbbbb", + "pygments.comment": "italic ansibrightblack", # "italic #3d7b7b", + "pygments.comment.preproc": "noitalic ansired", # "noitalic #9c6500", + "pygments.keyword": "bold ansigreen", # "bold #008000", + "pygments.keyword.pseudo": "nobold", + "pygments.keyword.type": "nobold ansired", # "nobold #b00040", + "pygments.operator": "ansibrightblack", # "#666666", + "pygments.operator.word": "bold ansimagenta", # "bold #aa22ff", + "pygments.name.builtin": "ansigreen", # "#008000", + "pygments.name.function": "ansibrightblue", # "#0000ff", + "pygments.name.class": "bold ansibrightblue", # "bold #0000ff", + "pygments.name.namespace": "bold ansibrightblack", # "bold #0000ff", + "pygments.name.exception": "bold ansired", # "bold #cb3f38", + "pygments.name.variable": "ansiblue", # "#19177c", + "pygments.name.constant": "ansired", # "#880000", + "pygments.name.label": "ansiyellow", # "#767600", + "pygments.name.entity": "bold ansibrightblack", # "bold #717171", + "pygments.name.attribute": "ansibrightblack", # "#687822", + "pygments.name.tag": "bold ansigreen", # "bold #008000", + "pygments.name.decorator": "ansimagenta", # "#aa22ff", + "pygments.literal.string": "ansired", # "#ba2121", + "pygments.literal.string.doc": "italic", + "pygments.literal.string.interpol": "bold ansibrightblack", # "bold #a45a77", + "pygments.literal.string.escape": "bold ansired", # "bold #aa5d1f", + "pygments.literal.string.regex": "ansibrightblack", # "#a45a77", + "pygments.literal.string.symbol": "ansiblue", # "#19177c", + "pygments.literal.string.other": "ansigreen", # "#008000", + "pygments.literal.number": "ansibrightblack", # "#666666", + "pygments.generic.heading": "bold ansiblue", # "bold #000080", + "pygments.generic.subheading": "bold ansimagenta", # "bold #800080", + "pygments.generic.deleted": "ansired", # "#a00000", + "pygments.generic.inserted": "ansigreen", # "#008400", + "pygments.generic.error": "ansigreen", # "#e40000", + "pygments.generic.emph": "italic", + "pygments.generic.strong": "bold", + "pygments.generic.emphstrong": "bold italic", + "pygments.generic.prompt": "bold ansiblue", # "bold #000080", + "pygments.generic.output": "ansibrightblack", # "#717171", + "pygments.generic.traceback": "ansiblue", # "#04d", + "pygments.error": "", # "border:#ff0000", +} + # Code style for Windows consoles. They support only 16 colors, # so we choose a combination that displays nicely. win32_code_style = { @@ -79,6 +137,14 @@ def generate_style(python_style: BaseStyle, ui_style: BaseStyle) -> BaseStyle: "completion.keyword": "fg:#008800", "completion.keyword fuzzymatch.inside": "fg:#008800", "completion.keyword fuzzymatch.outside": "fg:#44aa44", + # Styling for the meta completion menu that displays the type of each + # completion, e.g. param, builtin, keyword to the right of the item. + "completion.param-meta": "fg:ansiblue", + "completion.param-meta fuzzymatch.inside.character": "fg:ansiblue", + "completion.builtin-meta": "fg:ansigreen", + "completion.builtin-meta fuzzymatch.inside.character": "fg:ansigreen", + "completion.keyword-meta": "fg:ansired", + "completion.keyword-meta fuzzymatch.inside.character": "fg:ansired", # Separator between windows. (Used above docstring.) "separator": "#bbbbbb", # System toolbar diff --git a/ptpython/utils.py b/src/ptpython/utils.py similarity index 85% rename from ptpython/utils.py rename to src/ptpython/utils.py index 2fb24a41..92cfc2a1 100644 --- a/ptpython/utils.py +++ b/src/ptpython/utils.py @@ -1,13 +1,25 @@ """ For internal use only. """ + +from __future__ import annotations + import re -from typing import Callable, Iterable, Type, TypeVar, cast +from typing import TYPE_CHECKING, Any, Callable, Iterable, TypeVar, cast +from prompt_toolkit.document import Document from prompt_toolkit.formatted_text import to_formatted_text from prompt_toolkit.formatted_text.utils import fragment_list_to_text from prompt_toolkit.mouse_events import MouseEvent, MouseEventType +if TYPE_CHECKING: + from jedi import Interpreter + + # See: prompt_toolkit/key_binding/key_bindings.py + # Annotating these return types as `object` is what works best, because + # `NotImplemented` is typed `Any`. + NotImplementedOrNone = object + __all__ = [ "has_unclosed_brackets", "get_jedi_script_from_document", @@ -45,7 +57,9 @@ def has_unclosed_brackets(text: str) -> bool: return False -def get_jedi_script_from_document(document, locals, globals): +def get_jedi_script_from_document( + document: Document, locals: dict[str, Any], globals: dict[str, Any] +) -> Interpreter: import jedi # We keep this import in-line, to improve start-up time. # Importing Jedi is 'slow'. @@ -68,7 +82,7 @@ def get_jedi_script_from_document(document, locals, globals): # Workaround Jedi issue #514: for https://github.com/davidhalter/jedi/issues/514 return None except KeyError: - # Workaroud for a crash when the input is "u'", the start of a unicode string. + # Workaround for a crash when the input is "u'", the start of a unicode string. return None except Exception: # Workaround for: https://github.com/jonathanslenders/ptpython/issues/91 @@ -78,7 +92,7 @@ def get_jedi_script_from_document(document, locals, globals): _multiline_string_delims = re.compile("""[']{3}|["]{3}""") -def document_is_multiline_python(document): +def document_is_multiline_python(document: Document) -> bool: """ Determine whether this is a multiline Python document. """ @@ -133,7 +147,7 @@ def if_mousedown(handler: _T) -> _T: by the Window.) """ - def handle_if_mouse_down(mouse_event: MouseEvent): + def handle_if_mouse_down(mouse_event: MouseEvent) -> NotImplementedOrNone: if mouse_event.event_type == MouseEventType.MOUSE_DOWN: return handler(mouse_event) else: @@ -142,7 +156,7 @@ def handle_if_mouse_down(mouse_event: MouseEvent): return cast(_T, handle_if_mouse_down) -_T_type = TypeVar("_T_type", bound=Type) +_T_type = TypeVar("_T_type", bound=type) def ptrepr_to_repr(cls: _T_type) -> _T_type: @@ -154,7 +168,8 @@ def ptrepr_to_repr(cls: _T_type) -> _T_type: "@ptrepr_to_repr can only be applied to classes that have a `__pt_repr__` method." ) - def __repr__(self) -> str: + def __repr__(self: object) -> str: + assert hasattr(cls, "__pt_repr__") return fragment_list_to_text(to_formatted_text(cls.__pt_repr__(self))) cls.__repr__ = __repr__ # type:ignore diff --git a/ptpython/validator.py b/src/ptpython/validator.py similarity index 82% rename from ptpython/validator.py rename to src/ptpython/validator.py index 0f6a4eaf..cf2ee542 100644 --- a/ptpython/validator.py +++ b/src/ptpython/validator.py @@ -1,3 +1,8 @@ +from __future__ import annotations + +from typing import Callable + +from prompt_toolkit.document import Document from prompt_toolkit.validation import ValidationError, Validator from .utils import unindent_code @@ -13,10 +18,10 @@ class PythonValidator(Validator): active compiler flags. """ - def __init__(self, get_compiler_flags=None): + def __init__(self, get_compiler_flags: Callable[[], int] | None = None) -> None: self.get_compiler_flags = get_compiler_flags - def validate(self, document): + def validate(self, document: Document) -> None: """ Check input for Python syntax errors. """ @@ -45,7 +50,7 @@ def validate(self, document): # fixed in Python 3.) # TODO: This is not correct if indentation was removed. index = document.translate_row_col_to_index( - e.lineno - 1, (e.offset or 1) - 1 + (e.lineno or 1) - 1, (e.offset or 1) - 1 ) raise ValidationError(index, f"Syntax Error: {e}") except TypeError as e: @@ -54,4 +59,4 @@ def validate(self, document): except ValueError as e: # In Python 2, compiling "\x9" (an invalid escape sequence) raises # ValueError instead of SyntaxError. - raise ValidationError(0, "Syntax Error: %s" % e) + raise ValidationError(0, f"Syntax Error: {e}") diff --git a/tests/run_tests.py b/tests/run_tests.py deleted file mode 100755 index 2f945163..00000000 --- a/tests/run_tests.py +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env python -import unittest - -import ptpython.completer -import ptpython.eventloop -import ptpython.filters -import ptpython.history_browser -import ptpython.key_bindings -import ptpython.layout -import ptpython.python_input -import ptpython.repl -import ptpython.style -import ptpython.utils -import ptpython.validator - -# For now there are no tests here. -# However this is sufficient for Travis to do at least a syntax check. -# That way we are at least sure to restrict to the Python 2.6 syntax. - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_dummy.py b/tests/test_dummy.py new file mode 100755 index 00000000..922c6a39 --- /dev/null +++ b/tests/test_dummy.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python +from __future__ import annotations + +import ptpython.completer +import ptpython.eventloop +import ptpython.filters +import ptpython.history_browser +import ptpython.key_bindings +import ptpython.layout +import ptpython.python_input +import ptpython.repl +import ptpython.style +import ptpython.utils +import ptpython.validator + +# For now there are no tests here. +# However this is sufficient to do at least a syntax check. + + +def test_dummy() -> None: + assert ptpython.completer + assert ptpython.eventloop + assert ptpython.filters + assert ptpython.history_browser + assert ptpython.key_bindings + assert ptpython.layout + assert ptpython.python_input + assert ptpython.repl + assert ptpython.style + assert ptpython.utils + assert ptpython.validator