diff options
43 files changed, 1767 insertions, 892 deletions
diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 00ed1b0..9a50f3b 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.6, 3.7, 3.8, 3.9] + python-version: [3.7, 3.8, 3.9, "3.10", "3.11"] steps: - uses: actions/checkout@v2 @@ -22,13 +22,13 @@ jobs: 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 . ruff mypy pytest readme_renderer pip list - name: Type Checker run: | mypy ptpython - isort -c --profile black ptpython examples setup.py - black --check ptpython examples setup.py + ruff . + ruff format --check . - name: Run Tests run: | ./tests/run_tests.py @@ -1,7 +1,112 @@ CHANGELOG ========= -3.0.16: 2020-02-11 +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 +------------------ + +New features: +- For `DictionaryCompleter`: show parentheses after methods. + +Fixes: +- Don't crash when trying to complete broken mappings in `DictionaryCompleter`. +- Don't crash when an older version of `black` is installed that is not + compatible. + + +3.0.19: 2021-07-08 +------------------ + +Fixes: +- Fix handling of `SystemExit` (fixes "ValueError: I/O operation on closed + file"). +- Allow usage of `await` in assignment expressions or for-loops. + + +3.0.18: 2021-06-26 +------------------ + +Fixes: +- Made "black" an optional dependency. + + +3.0.17: 2021-03-22 +------------------ + +Fixes: +- Fix leaking file descriptors due to not closing the asyncio event loop after + reading input in a thread. +- Fix race condition during retrieval of signatures. + + +3.0.16: 2021-02-11 ------------------ (Commit 7f619e was missing in previous release.) @@ -13,7 +118,7 @@ Fixes: completions were missed out if the fuzzy completer doesn't find them. -3.0.15: 2020-02-11 +3.0.15: 2021-02-11 ------------------ New features: @@ -23,7 +128,7 @@ Fixes: - Fix `AttributeError` during retrieval of signatures with type annotations. -3.0.14: 2020-02-10 +3.0.14: 2021-02-10 ------------------ New features: @@ -42,7 +147,7 @@ Fixes: - Hide signature when sidebar is visible. -3.0.13: 2020-01-26 +3.0.13: 2021-01-26 ------------------ New features: @@ -57,7 +162,7 @@ Fixes: - Fix line ending bug in pager. -3.0.12: 2020-01-24 +3.0.12: 2021-01-24 ------------------ New features: @@ -71,7 +176,7 @@ Fixes: - Properly handle `SystemExit`. -3.0.11: 2020-01-20 +3.0.11: 2021-01-20 ------------------ New features: @@ -94,7 +199,7 @@ Fixes: - Don't execute PYTHONSTARTUP when -i flag was given. -3.0.10: 2020-01-13 +3.0.10: 2021-01-13 ------------------ Fixes: @@ -103,7 +208,7 @@ Fixes: default. -3.0.9: 2020-01-10 +3.0.9: 2021-01-10 ----------------- New features: @@ -112,7 +217,7 @@ New features: - Show REPL title in pager. -3.0.8: 2020-01-05 +3.0.8: 2021-01-05 ----------------- New features: @@ -120,7 +225,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: @@ -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, @@ -50,6 +50,41 @@ Features [2] If the terminal supports it (most terminals do), this allows pasting without going into paste mode. It will keep the indentation. +Command Line Options +******************** + +The help menu shows basic command-line options. + +:: + + $ ptpython --help + usage: ptpython [-h] [--vi] [-i] [--light-bg] [--dark-bg] [--config-file CONFIG_FILE] + [--history-file HISTORY_FILE] [-V] + [args ...] + + ptpython: Interactive Python shell. + + positional arguments: + args Script and arguments + + optional arguments: + -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 + Location of configuration file. + --history-file HISTORY_FILE + Location of history file. + -V, --version show program's version number and exit + + environment variables: + PTPYTHON_CONFIG_HOME: a configuration directory to use + PYTHONSTARTUP: file executed on interactive startup (no default) + + __pt_repr__: A nicer repr with colors ************************************* @@ -109,6 +144,8 @@ like this: else: sys.exit(embed(globals(), locals())) +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. Multiline editing ***************** @@ -135,6 +172,20 @@ error. .. image :: https://github.com/jonathanslenders/ptpython/raw/master/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 ******************* @@ -159,6 +210,9 @@ is looked for. Have a look at this example to see what is possible: `config.py <https://github.com/jonathanslenders/ptpython/blob/master/examples/ptpython_config/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. + IPython support *************** @@ -174,7 +228,7 @@ This is also available for embedding: .. code:: python - from ptpython.ipython.repl import embed + from ptpython.ipython import embed embed(globals(), locals()) @@ -211,7 +265,7 @@ FAQ **Q**: The ``Meta``-key doesn't work. -**A**: For some terminals you have to enable the Alt-key to act as meta key, but you +**A**: For some terminals you have to enable the Alt-key to act as meta key, but you can also type ``Escape`` before any key instead. diff --git a/debian/changelog b/debian/changelog index 8fbeab7..983d31f 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,118 @@ +ptpython (3.0.26-3) sid; urgency=medium + + * Uploading to sid. + * Migrating from appdirs to platformdirs (Closes: #1067983). + + -- Daniel Baumann <daniel.baumann@progress-linux.org> Sun, 14 Apr 2024 10:23:45 +0200 + +ptpython (3.0.26-2) sid; urgency=medium + + * Uploading to sid. + * Updating to standards-version 4.7.0. + * Removing unused python2 executables. + + -- Daniel Baumann <daniel.baumann@progress-linux.org> Tue, 09 Apr 2024 11:30:48 +0200 + +ptpython (3.0.26-1) sid; urgency=medium + + * Uploading to sid. + * Merging upstream version 3.0.26. + * Updating copyright for 2024. + + -- Daniel Baumann <daniel.baumann@progress-linux.org> Wed, 07 Feb 2024 07:14:14 +0100 + +ptpython (3.0.25-1) sid; urgency=medium + + * Uploading to sid. + * Merging upstream version 3.0.25. + * Switching to PEP517 pybuild plugin. + * Bumping build-depends of prompt-toolkit. + + -- Daniel Baumann <mail@daniel-baumann.ch> Sun, 17 Dec 2023 11:46:53 +0100 + +ptpython (3.0.23-3) sid; urgency=medium + + * Uploading to sid. + * Manually removing some files that pybuild doesn't clean up during + build (Closes: #1045757). + + -- Daniel Baumann <daniel.baumann@progress-linux.org> Mon, 14 Aug 2023 12:08:59 +0200 + +ptpython (3.0.23-2) sid; urgency=medium + + * Uploading to sid. + * Uploading without changes after bookworm release. + + -- Daniel Baumann <daniel.baumann@progress-linux.org> Sun, 11 Jun 2023 14:43:07 +0200 + +ptpython (3.0.23-1) experimental; urgency=medium + + * Uploading to experimental. + * Merging upstream version 3.0.23. + + -- Daniel Baumann <daniel.baumann@progress-linux.org> Mon, 27 Feb 2023 11:40:21 +0100 + +ptpython (3.0.22-2) sid; urgency=medium + + * Uploading to sid. + * Updating to standards version 4.6.2. + + -- Daniel Baumann <daniel.baumann@progress-linux.org> Mon, 30 Jan 2023 17:58:19 +0100 + +ptpython (3.0.22-1) sid; urgency=medium + + * Uploading to sid. + * Merging upstream version 3.0.22. + + -- Daniel Baumann <daniel.baumann@progress-linux.org> Mon, 12 Dec 2022 16:39:59 +0100 + +ptpython (3.0.21-1) sid; urgency=medium + + * Uploading to sid. + * Merging upstream version 3.0.21. + * Updating copyright for 2022. + * Updating to standards version 4.6.1. + + -- Daniel Baumann <daniel.baumann@progress-linux.org> Fri, 02 Dec 2022 10:11:50 +0100 + +ptpython (3.0.20-2) sid; urgency=medium + + * Uploading to sid. + * Updating watch file. + + -- Daniel Baumann <daniel.baumann@progress-linux.org> Wed, 15 Dec 2021 06:17:58 +0100 + +ptpython (3.0.20-1) sid; urgency=medium + + * Uploading to sid. + * Merging upstream version 3.0.20. + + -- Daniel Baumann <daniel.baumann@progress-linux.org> Mon, 08 Nov 2021 14:25:35 +0100 + +ptpython (3.0.19-2) sid; urgency=medium + + * Uploading to sid. + * Updating watch file. + * Updating to standards version 4.6.0. + + -- Daniel Baumann <daniel.baumann@progress-linux.org> Sat, 09 Oct 2021 10:54:47 +0200 + +ptpython (3.0.19-1) sid; urgency=medium + + * Uploading to sid. + * Merging upstream version 3.0.19. + + -- Daniel Baumann <daniel.baumann@progress-linux.org> Sat, 17 Jul 2021 09:41:05 +0200 + +ptpython (3.0.16-2) sid; urgency=medium + + * Uploading to sid. + * Updating python debhelper sequence, thanks to Andrej Shadura + <andrewsh@debian.org>. + * Adding upstream metadata, thanks to Andrej Shadura <andrewsh@debian.org>. + + -- Daniel Baumann <daniel.baumann@progress-linux.org> Tue, 09 Mar 2021 11:33:42 +0100 + ptpython (3.0.16-1) sid; urgency=medium * Initial upload to sid (Closes: #869534). diff --git a/debian/control b/debian/control index 9268778..cfaf5e2 100644 --- a/debian/control +++ b/debian/control @@ -5,12 +5,13 @@ Maintainer: Daniel Baumann <daniel.baumann@progress-linux.org> Build-Depends: debhelper-compat (= 13), dh-sequence-python3, + pybuild-plugin-pyproject, python3, - python3-prompt-toolkit (>= 3.0.16), + python3-prompt-toolkit (>= 3.0.43), python3-pygments, python3-setuptools, Rules-Requires-Root: no -Standards-Version: 4.5.1 +Standards-Version: 4.7.0 Homepage: https://github.com/prompt-toolkit/ptpython Vcs-Browser: https://git.progress-linux.org/users/daniel.baumann/debian/packages/ptpython Vcs-Git: https://git.progress-linux.org/users/daniel.baumann/debian/packages/ptpython diff --git a/debian/copyright b/debian/copyright index dd98bff..485757e 100644 --- a/debian/copyright +++ b/debian/copyright @@ -4,11 +4,11 @@ Upstream-Contact: Jonathan Slenders <jonathan@slenders.be> Source: https://github.com/prompt-toolkit/ptpython/releases Files: * -Copyright: 2015-2020 Jonathan Slenders <jonathan@slenders.be> +Copyright: 2015-2024 Jonathan Slenders <jonathan@slenders.be> License: BSD-3-clause Files: debian/* -Copyright: 2021 Daniel Baumann <daniel.baumann@progress-linux.org> +Copyright: 2021-2024 Daniel Baumann <daniel.baumann@progress-linux.org> License: BSD-3-clause License: BSD-3-clause diff --git a/debian/patches/debian/0001-platformdirs.patch b/debian/patches/debian/0001-platformdirs.patch new file mode 100644 index 0000000..06186e7 --- /dev/null +++ b/debian/patches/debian/0001-platformdirs.patch @@ -0,0 +1,53 @@ +Author: Daniel Baumann <daniel.baumann@progress-linux.org> +Description: Migrating from appdirs to platformdirs (Closes: #1067983). + +diff -Naurp ptpython.orig/README.rst ptpython/README.rst +--- ptpython.orig/README.rst ++++ ptpython/README.rst +@@ -201,8 +201,8 @@ Configuration + ************* + + It is possible to create a ``config.py`` file to customize configuration. +-ptpython will look in an appropriate platform-specific directory via `appdirs +-<https://pypi.org/project/appdirs/>`. See the ``appdirs`` documentation for the ++ptpython will look in an appropriate platform-specific directory via `platformdirs ++<https://pypi.org/project/platformdirs/>`. See the ``platformdirs`` documentation for the + precise location for your platform. A ``PTPYTHON_CONFIG_HOME`` environment + variable, if set, can also be used to explicitly override where configuration + is looked for. +diff -Naurp ptpython.orig/ptpython/entry_points/run_ptpython.py ptpython/ptpython/entry_points/run_ptpython.py +--- ptpython.orig/ptpython/entry_points/run_ptpython.py ++++ ptpython/ptpython/entry_points/run_ptpython.py +@@ -32,7 +32,7 @@ import sys + from textwrap import dedent + from typing import IO + +-import appdirs ++import platformdirs + from prompt_toolkit.formatted_text import HTML + from prompt_toolkit.shortcuts import print_formatted_text + +@@ -106,9 +106,9 @@ def get_config_and_history_file(namespac + """ + config_dir = os.environ.get( + "PTPYTHON_CONFIG_HOME", +- appdirs.user_config_dir("ptpython", "prompt_toolkit"), ++ platformdirs.user_config_dir("ptpython", "prompt_toolkit"), + ) +- data_dir = appdirs.user_data_dir("ptpython", "prompt_toolkit") ++ data_dir = platformdirs.user_data_dir("ptpython", "prompt_toolkit") + + # Create directories. + for d in (config_dir, data_dir): +diff -Naurp ptpython.orig/setup.py ptpython/setup.py +--- ptpython.orig/setup.py ++++ ptpython/setup.py +@@ -18,7 +18,7 @@ setup( + packages=find_packages("."), + package_data={"ptpython": ["py.typed"]}, + install_requires=[ +- "appdirs", ++ "platformdirs", + "importlib_metadata;python_version<'3.8'", + "jedi>=0.16.0", + # Use prompt_toolkit 3.0.34, because of `OneStyleAndTextTuple` import. diff --git a/debian/patches/series b/debian/patches/series new file mode 100644 index 0000000..f0ceaa0 --- /dev/null +++ b/debian/patches/series @@ -0,0 +1 @@ +debian/0001-platformdirs.patch diff --git a/debian/rules b/debian/rules index 8eeaff8..dc6ae76 100755 --- a/debian/rules +++ b/debian/rules @@ -4,3 +4,12 @@ export PYBUILD_NAME=ptpython %: dh ${@} --buildsystem=pybuild + +execute_after_dh_auto_clean: + # help pybuild + rm -rf *.egg-info + +execute_after_dh_auto_install: + # removing unused files + rm -f debian/ptpython/usr/bin/ptpython + rm -f debian/ptpython/usr/bin/ptipython diff --git a/debian/watch b/debian/watch index 38fb031..89e5f3b 100644 --- a/debian/watch +++ b/debian/watch @@ -1,3 +1,2 @@ version=4 -opts=filenamemangle=s/.+\/v?(\d\S+)\.tar\.gz/ptpython-$1\.tar\.gz/ \ -https://github.com/prompt-toolkit/ptpython/releases .*/v?(\d\S+)\.tar\.gz +https://github.com/prompt-toolkit/ptpython/tags .*/archive/refs/tags/@ANY_VERSION@@ARCHIVE_EXT@ diff --git a/docs/concurrency-challenges.rst b/docs/concurrency-challenges.rst index b56d969..0ff9c6c 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 05f52f1..a8fbba5 100755 --- a/examples/asyncio-python-embed.py +++ b/examples/asyncio-python-embed.py @@ -19,7 +19,7 @@ loop = asyncio.get_event_loop() counter = [0] -async def print_counter(): +async def print_counter() -> None: """ Coroutine that prints counters and saves it in a global variable. """ @@ -29,7 +29,7 @@ async def print_counter(): 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 +44,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 86b5607..be0689e 100755 --- a/examples/asyncio-ssh-python-embed.py +++ b/examples/asyncio-ssh-python-embed.py @@ -32,31 +32,25 @@ class MySSHServer(asyncssh.SSHServer): 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) - 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 8532f93..b25850a 100644 --- a/examples/ptpython_config/config.py +++ b/examples/ptpython_config/config.py @@ -3,6 +3,7 @@ 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 """ from prompt_toolkit.filters import ViInsertMode from prompt_toolkit.key_binding.key_processor import KeyPress @@ -49,7 +50,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 +70,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 @@ -106,8 +110,13 @@ def configure(repl): repl.enable_input_validation = True # Use this colorscheme for the code. + # Ptpython uses Pygments for code styling, so you can choose from Pygments' + # color schemes. See: + # https://pygments.org/docs/styles/ + # https://pygments.org/demo/ repl.use_code_colorscheme("default") - # repl.use_code_colorscheme("pastie") + # A colorscheme that looks good on dark backgrounds is 'native': + # repl.use_code_colorscheme("native") # Set color depth (keep in mind that not all terminals support true color). @@ -157,7 +166,7 @@ def configure(repl): @repl.add_key_binding("j", "j", filter=ViInsertMode()) def _(event): " Map 'jj' to Escape. " - event.cli.key_processor.feed(KeyPress("escape")) + event.cli.key_processor.feed(KeyPress(Keys("escape"))) """ # Custom key binding for some simple autocorrection while typing. diff --git a/examples/python-embed-with-custom-prompt.py b/examples/python-embed-with-custom-prompt.py index 968aedc..d54da1d 100755 --- a/examples/python-embed-with-custom-prompt.py +++ b/examples/python-embed-with-custom-prompt.py @@ -2,26 +2,26 @@ """ 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("<ansigreen>Input[%s]</ansigreen>: ") % ( 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("<ansired>Result[%s]</ansired>: ") % ( repl.current_statement_index, ) @@ -30,7 +30,7 @@ def configure(repl): 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 ac2cd06..49224ac 100755 --- a/examples/python-embed.py +++ b/examples/python-embed.py @@ -4,7 +4,7 @@ from ptpython.repl import embed -def main(): +def main() -> None: embed(globals(), locals(), vi_mode=False) diff --git a/examples/ssh-and-telnet-embed.py b/examples/ssh-and-telnet-embed.py index 378784c..62fa76d 100755 --- a/examples/ssh-and-telnet-embed.py +++ b/examples/ssh-and-telnet-embed.py @@ -11,13 +11,16 @@ import pathlib import asyncssh from prompt_toolkit import print_formatted_text -from prompt_toolkit.contrib.ssh.server import PromptToolkitSSHServer +from prompt_toolkit.contrib.ssh.server import ( + PromptToolkitSSHServer, + PromptToolkitSSHSession, +) from prompt_toolkit.contrib.telnet.server import 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 +28,12 @@ def ensure_key(filename="ssh_host_key"): return str(path) -async def interact(connection=None): +async def interact(connection: PromptToolkitSSHSession) -> 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/ptpython/__init__.py b/ptpython/__init__.py index 4908eba..63c6233 100644 --- a/ptpython/__init__.py +++ b/ptpython/__init__.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from .repl import embed __all__ = ["embed"] diff --git a/ptpython/__main__.py b/ptpython/__main__.py index 83340a7..c006261 100644 --- a/ptpython/__main__.py +++ b/ptpython/__main__.py @@ -1,6 +1,8 @@ """ 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/ptpython/completer.py index 9f7e10b..91d6647 100644 --- a/ptpython/completer.py +++ b/ptpython/completer.py @@ -1,10 +1,12 @@ +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 typing import TYPE_CHECKING, Any, Callable, Iterable from prompt_toolkit.completion import ( CompleteEvent, @@ -21,6 +23,7 @@ from prompt_toolkit.formatted_text import fragment_list_to_text, to_formatted_te 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 +46,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 +60,8 @@ class PythonCompleter(Completer): 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 +76,7 @@ class PythonCompleter(Completer): 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 +87,7 @@ class PythonCompleter(Completer): 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 +191,6 @@ class PythonCompleter(Completer): ): # 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 +202,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 +243,7 @@ class JediCompleter(Completer): # 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 +259,7 @@ class JediCompleter(Completer): # 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. @@ -296,7 +302,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 +367,7 @@ class DictionaryCompleter(Completer): rf""" {expression} - # Dict loopup to complete (square bracket open + start of + # Dict lookup to complete (square bracket open + start of # string). \[ \s* ([^\[\]]*)$ @@ -370,14 +380,14 @@ class DictionaryCompleter(Completer): 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 +400,6 @@ class DictionaryCompleter(Completer): 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 +431,7 @@ class DictionaryCompleter(Completer): 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 +446,7 @@ class DictionaryCompleter(Completer): 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 +454,6 @@ class DictionaryCompleter(Completer): 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 +469,29 @@ class DictionaryCompleter(Completer): 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: - " 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 + def meta_repr(value: object) -> Callable[[], str]: + "Abbreviate meta text, make sure it fits on one line." + + # 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). + def get_value_repr() -> str: + text = self._do_repr(value) + + # Take first line, if multiple lines. + if "\n" in text: + text = text.split("\n", 1)[0] + "..." + + return text + + return get_value_repr match = self.item_lookup_pattern.search(document.text_before_cursor) if match is not None: @@ -495,7 +512,7 @@ class DictionaryCompleter(Completer): else: break - for k in result: + for k, v in result.items(): if str(k).startswith(str(key_obj)): try: k_repr = self._do_repr(k) @@ -503,7 +520,7 @@ class DictionaryCompleter(Completer): k_repr + "]", -len(key), display=f"[{k_repr}]", - display_meta=abbr_meta(self._do_repr(result[k])), + display_meta=meta_repr(v), ) except ReprFailedError: pass @@ -519,8 +536,12 @@ class DictionaryCompleter(Completer): 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 @@ -528,7 +549,7 @@ class DictionaryCompleter(Completer): self, document: Document, complete_event: CompleteEvent, - temp_locals: Dict[str, Any], + temp_locals: dict[str, Any], ) -> Iterable[Completion]: """ Complete attribute names. @@ -545,12 +566,11 @@ class DictionaryCompleter(Completer): def get_suffix(name: str) -> str: try: obj = getattr(result, name, None) - if inspect.isfunction(obj): + 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 @@ -561,13 +581,13 @@ class DictionaryCompleter(Completer): 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("_"): @@ -579,7 +599,7 @@ class DictionaryCompleter(Completer): 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 @@ -597,7 +617,6 @@ class HidePrivateCompleter(Completer): def get_completions( self, document: Document, complete_event: CompleteEvent ) -> Iterable[Completion]: - completions = list(self.completer.get_completions(document, complete_event)) complete_private_attributes = self.complete_private_attributes() hide_private = False @@ -621,7 +640,7 @@ class HidePrivateCompleter(Completer): class ReprFailedError(Exception): - " Raised when the repr() call in `DictionaryCompleter` fails. " + "Raised when the repr() call in `DictionaryCompleter` fails." try: @@ -632,7 +651,9 @@ except ImportError: # Python 2. _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/asyncssh_repl.py b/ptpython/contrib/asyncssh_repl.py index 4c36217..2f74eb2 100644 --- a/ptpython/contrib/asyncssh_repl.py +++ b/ptpython/contrib/asyncssh_repl.py @@ -6,21 +6,23 @@ Note that the code in this file is Python 3 only. However, we 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 +31,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 +79,7 @@ class ReplSSHServerSession(asyncssh.SSHServerSession): 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 +89,7 @@ class ReplSSHServerSession(asyncssh.SSHServerSession): 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 +98,28 @@ class ReplSSHServerSession(asyncssh.SSHServerSession): 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/run_ptipython.py b/ptpython/entry_points/run_ptipython.py index 650633e..b660a0a 100644 --- a/ptpython/entry_points/run_ptipython.py +++ b/ptpython/entry_points/run_ptipython.py @@ -1,4 +1,6 @@ #!/usr/bin/env python +from __future__ import annotations + import os import sys @@ -31,7 +33,7 @@ def run(user_ns=None): path = a.args[0] with open(path, "rb") as f: code = compile(f.read(), path, "exec") - exec(code, {}) + exec(code, {"__name__": "__main__", "__file__": path}) else: enable_deprecation_warnings() @@ -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/ptpython/entry_points/run_ptpython.py index 0b3dbdb..1d4a532 100644 --- a/ptpython/entry_points/run_ptpython.py +++ b/ptpython/entry_points/run_ptpython.py @@ -9,6 +9,7 @@ optional arguments: -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,21 +22,24 @@ environment variables: 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 textwrap import dedent -from typing import Tuple +from typing import IO 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 + from importlib import metadata # type: ignore except ImportError: import importlib_metadata as metadata # type: ignore @@ -44,7 +48,7 @@ __all__ = ["create_parser", "get_config_and_history_file", "run"] class _Parser(argparse.ArgumentParser): - def print_help(self): + def print_help(self, file: IO[str] | None = None) -> None: super().print_help() print( dedent( @@ -67,15 +71,20 @@ def create_parser() -> _Parser: 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. @@ -179,16 +188,18 @@ def run() -> None: path = a.args[0] with open(path, "rb") as f: code = compile(f.read(), path, "exec") - # NOTE: We have to pass an empty dictionary as namespace. Omitting - # this argument causes imports to not be found. See issue #326. - exec(code, {}) + # NOTE: We have to pass a dict as namespace. Omitting this argument + # causes imports to not be found. See issue #326. + # However, an empty dict sets __name__ to 'builtins', which + # breaks `if __name__ == '__main__'` checks. See issue #444. + exec(code, {"__name__": "__main__", "__file__": path}) # Run interactive shell. else: 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) @@ -202,7 +213,7 @@ def run() -> None: import __main__ - embed( + embed_result = embed( # type: ignore vi_mode=a.vi, history_filename=history_file, configure=configure, @@ -210,8 +221,14 @@ def run() -> 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/ptpython/eventloop.py index c841972..14ab64b 100644 --- a/ptpython/eventloop.py +++ b/ptpython/eventloop.py @@ -7,13 +7,17 @@ way we don't block the UI of for instance ``turtle`` and other Tk libraries. 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. @@ -23,9 +27,9 @@ def _inputhook_tk(inputhook_context): import _tkinter # Keep this imports inline! - 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 _inputhook_tk(inputhook_context): # 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 _inputhook_tk(inputhook_context): 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 _inputhook_tk(inputhook_context): 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/ptpython/filters.py index 1adac13..a2079fd 100644 --- a/ptpython/filters.py +++ b/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 @@ __all__ = ["HasSignature", "ShowSidebar", "ShowSignature", "ShowDocstring"] 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/ptpython/history_browser.py index 798a280..b667be1 100644 --- a/ptpython/history_browser.py +++ b/ptpython/history_browser.py @@ -4,7 +4,10 @@ Utility to easily select lines from the history and execute them again. `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 +15,11 @@ from prompt_toolkit.buffer import Buffer 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 +30,23 @@ from prompt_toolkit.layout.containers import ( 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 +56,15 @@ from ptpython.layout import get_inputmode_fragments from .utils import if_mousedown +if TYPE_CHECKING: + from .python_input import PythonInput + HISTORY_COUNT = 2000 __all__ = ["HistoryLayout", "PythonHistory"] +E = KeyPressEvent + HELP_TEXT = """ This interface is meant to select multiple lines from the history and execute them together. @@ -85,7 +106,8 @@ Further, remember that searching works like in Emacs class BORDER: - " Box drawing characters. " + "Box drawing characters." + HORIZONTAL = "\u2501" VERTICAL = "\u2503" TOP_LEFT = "\u250f" @@ -109,7 +131,7 @@ class HistoryLayout: application. """ - def __init__(self, history): + def __init__(self, history: PythonHistory) -> None: search_toolbar = SearchToolbar() self.help_buffer_control = BufferControl( @@ -201,19 +223,19 @@ class HistoryLayout: 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 +261,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 +279,7 @@ class HistoryMargin(Margin): 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 +310,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 +329,7 @@ class ResultMargin(Margin): 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 +350,7 @@ class ResultMargin(Margin): return result - def invalidation_hash(self, document): + def invalidation_hash(self, document: Document) -> int: return document.cursor_position_row @@ -333,13 +359,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 +385,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)) @@ -389,7 +422,7 @@ class HistoryMapping: 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,14 +446,14 @@ class HistoryMapping: 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): - " Display/hide help. " +def _toggle_help(history: PythonHistory) -> None: + "Display/hide help." help_buffer_control = history.history_layout.help_buffer_control if history.app.layout.current_control == help_buffer_control: @@ -429,8 +462,8 @@ def _toggle_help(history): history.app.layout.current_control = help_buffer_control -def _select_other_window(history): - " Toggle focus between left/right window. " +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 +474,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 +486,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 +523,7 @@ def create_key_bindings(history, python_input, history_mapping): @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,58 +549,58 @@ def create_key_bindings(history, python_input, history_mapping): @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): - " Select other window. " + def _(event: E) -> None: + "Select other window." _select_other_window(history) @handle("f4") - def _(event): - " Switch between Emacs/Vi mode. " + def _(event: E) -> None: + "Switch between Emacs/Vi mode." python_input.vi_mode = not python_input.vi_mode @handle("f1") - def _(event): - " Display/hide help. " + def _(event: E) -> None: + "Display/hide help." _toggle_help(history) @handle("enter", filter=help_focussed) @handle("c-c", filter=help_focussed) @handle("c-g", filter=help_focussed) @handle("escape", filter=help_focussed) - def _(event): - " Leave help. " + def _(event: E) -> None: + "Leave help." event.app.layout.focus_previous() @handle("q", filter=main_buffer_focussed) @handle("f3", filter=main_buffer_focussed) @handle("c-c", filter=main_buffer_focussed) @handle("c-g", filter=main_buffer_focussed) - def _(event): - " Cancel and go back. " + def _(event: E) -> None: + "Cancel and go back." event.app.exit(result=None) @handle("enter", filter=main_buffer_focussed) - def _(event): - " Accept input. " + 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): - " Suspend to background. " + def _(event: E) -> None: + "Suspend to background." event.app.suspend_to_background() return bindings 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 +614,14 @@ class PythonHistory: + 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,7 +636,7 @@ class PythonHistory: 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, @@ -605,7 +644,7 @@ class PythonHistory: 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,8 +668,8 @@ class PythonHistory: ) ) - def _history_buffer_pos_changed(self, _): - """ When the cursor changes in the history buffer. Synchronize. """ + 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: line_no = self.history_buffer.document.cursor_position_row diff --git a/ptpython/ipython.py b/ptpython/ipython.py index 2e8d119..ad0516a 100644 --- a/ptpython/ipython.py +++ b/ptpython/ipython.py @@ -8,13 +8,17 @@ also the power of for instance all the %-magic functions that IPython has to 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 +29,18 @@ from prompt_toolkit.contrib.regular_languages.compiler import compile 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 +53,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,7 +149,9 @@ 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"]): @@ -154,7 +163,9 @@ 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 @@ -201,6 +212,12 @@ class IPythonInput(PythonInput): 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 +257,7 @@ class InteractiveShellEmbed(_InteractiveShellEmbed): 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: @@ -269,6 +286,25 @@ def initialize_extensions(shell, extensions): 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,4 +318,22 @@ 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) + + +def run_startup_scripts(shell): + """ + Contributed by linyuxu: + https://github.com/prompt-toolkit/ptpython/issues/126#issue-161242480 + """ + import glob + import os + + startup_dir = shell.profile_dir.startup_dir + startup_files = [] + startup_files += glob.glob(os.path.join(startup_dir, "*.py")) + startup_files += glob.glob(os.path.join(startup_dir, "*.ipy")) + for file in startup_files: + shell.run_cell(open(file).read()) diff --git a/ptpython/key_bindings.py b/ptpython/key_bindings.py index 86317f9..d7bb575 100644 --- a/ptpython/key_bindings.py +++ b/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,25 @@ from prompt_toolkit.filters import ( ) 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 .python_input import PythonInput + __all__ = [ "load_python_bindings", "load_sidebar_bindings", "load_confirm_exit_bindings", ] +E = 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 +49,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 +59,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 +78,7 @@ def load_python_bindings(python_input): handle("c-w")(get_by_name("backward-kill-word")) @handle("f2") - def _(event): + def _(event: E) -> None: """ Show/hide sidebar. """ @@ -78,21 +89,21 @@ def load_python_bindings(python_input): 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 +112,14 @@ def load_python_bindings(python_input): @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 +131,7 @@ def load_python_bindings(python_input): & ~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 +154,21 @@ def load_python_bindings(python_input): & 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 +198,7 @@ def load_python_bindings(python_input): not get_app().current_buffer.text ), ) - def _(event): + def _(event: E) -> None: """ Override Control-D exit, to ask for confirmation. """ @@ -202,14 +213,14 @@ def load_python_bindings(python_input): event.app.exit(exception=EOFError) @handle("c-c", filter=has_focus(python_input.default_buffer)) - def _(event): - " Abort when Control-C has been pressed. " + 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,8 +232,8 @@ def load_sidebar_bindings(python_input): @handle("up", filter=sidebar_visible) @handle("c-p", filter=sidebar_visible) @handle("k", filter=sidebar_visible) - def _(event): - " Go to previous option. " + def _(event: E) -> None: + "Go to previous option." python_input.selected_option_index = ( python_input.selected_option_index - 1 ) % python_input.option_count @@ -230,8 +241,8 @@ def load_sidebar_bindings(python_input): @handle("down", filter=sidebar_visible) @handle("c-n", filter=sidebar_visible) @handle("j", filter=sidebar_visible) - def _(event): - " Go to next option. " + def _(event: E) -> None: + "Go to next option." python_input.selected_option_index = ( python_input.selected_option_index + 1 ) % python_input.option_count @@ -239,15 +250,15 @@ def load_sidebar_bindings(python_input): @handle("right", filter=sidebar_visible) @handle("l", filter=sidebar_visible) @handle(" ", filter=sidebar_visible) - def _(event): - " Select next value for current option. " + 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): - " Select previous value for current option. " + def _(event: E) -> None: + "Select previous value for current option." option = python_input.selected_option option.activate_previous() @@ -256,15 +267,15 @@ def load_sidebar_bindings(python_input): @handle("c-d", filter=sidebar_visible) @handle("enter", filter=sidebar_visible) @handle("escape", filter=sidebar_visible) - def _(event): - " Hide sidebar. " + def _(event: E) -> None: + "Hide sidebar." python_input.show_sidebar = False event.app.layout.focus_last() 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 +288,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 +305,7 @@ def load_confirm_exit_bindings(python_input): 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/ptpython/layout.py index 6482cbd..2c1ec15 100644 --- a/ptpython/layout.py +++ b/ptpython/layout.py @@ -1,11 +1,13 @@ """ 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 +21,7 @@ from prompt_toolkit.formatted_text import fragment_list_width, to_formatted_text 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 +43,10 @@ from prompt_toolkit.layout.processors import ( 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 +56,9 @@ from prompt_toolkit.widgets.toolbars import ( 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: @@ -64,33 +68,34 @@ __all__ = ["PtPythonLayout", "CompletionVisualisation"] class CompletionVisualisation(Enum): - " Visualisation method for the completions. " + "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,7 +103,7 @@ 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", " "), @@ -116,7 +121,7 @@ def python_sidebar(python_input: "PythonInput") -> Window: @if_mousedown def goto_next(mouse_event: MouseEvent) -> None: - " Select item and go to next value. " + "Select item and go to next value." python_input.selected_option_index = index option = python_input.selected_option option.activate_next() @@ -142,7 +147,7 @@ def python_sidebar(python_input: "PythonInput") -> Window: 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 +155,10 @@ def python_sidebar(python_input: "PythonInput") -> Window: 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 +170,12 @@ def python_sidebar(python_input: "PythonInput") -> Window: ) -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 +196,13 @@ def python_sidebar_navigation(python_input): ) -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 +214,7 @@ def python_sidebar_help(python_input): i += 1 return "" - def get_help_text(): + def get_help_text() -> StyleAndTextTuples: return [(token, get_current_description())] return ConditionalContainer( @@ -225,7 +230,7 @@ def python_sidebar_help(python_input): ) -def signature_toolbar(python_input): +def signature_toolbar(python_input: PythonInput) -> Container: """ Return the `Layout` for the signature. """ @@ -293,13 +298,15 @@ def signature_toolbar(python_input): 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 +318,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) 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. """ @@ -403,7 +412,7 @@ def status_bar(python_input: "PythonInput") -> Container: ) -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 get_inputmode_fragments(python_input: "PythonInput") -> StyleAndTextTuples: 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 get_inputmode_fragments(python_input: "PythonInput") -> StyleAndTextTuples: 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.) @@ -472,7 +481,7 @@ def show_sidebar_button_info(python_input: "PythonInput") -> Container: @if_mousedown def toggle_sidebar(mouse_event: MouseEvent) -> None: - " Click handler for the menu. " + "Click handler for the menu." python_input.show_sidebar = not python_input.show_sidebar version = sys.version_info @@ -510,7 +519,7 @@ def show_sidebar_button_info(python_input: "PythonInput") -> Container: 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. @@ -534,7 +543,7 @@ def create_exit_confirmation( ) -def meta_enter_message(python_input: "PythonInput") -> Container: +def meta_enter_message(python_input: PythonInput) -> Container: """ Create the `Layout` for the 'Meta+Enter` message. """ @@ -544,7 +553,7 @@ def meta_enter_message(python_input: "PythonInput") -> Container: @Condition def extra_condition() -> bool: - " Only show when... " + "Only show when..." b = python_input.default_buffer return ( @@ -566,23 +575,23 @@ def meta_enter_message(python_input: "PythonInput") -> Container: 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 +602,7 @@ class PtPythonLayout: 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 +632,7 @@ class PtPythonLayout: 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, @@ -646,7 +656,7 @@ class PtPythonLayout: sidebar = python_sidebar(python_input) self.exit_confirmation = create_exit_confirmation(python_input) - root_container = HSplit( + self.root_container = HSplit( [ VSplit( [ @@ -654,7 +664,7 @@ class PtPythonLayout: [ FloatContainer( content=HSplit( - [create_python_input_window()] + extra_body + [create_python_input_window()] + extra_body_list ), floats=[ Float( @@ -759,5 +769,5 @@ class PtPythonLayout: ] ) - self.layout = Layout(root_container) + self.layout = Layout(self.root_container) self.sidebar = sidebar diff --git a/ptpython/lexer.py b/ptpython/lexer.py index 62e470f..d925e95 100644 --- a/ptpython/lexer.py +++ b/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/ptpython/printer.py b/ptpython/printer.py new file mode 100644 index 0000000..85bd9c8 --- /dev/null +++ b/ptpython/printer.py @@ -0,0 +1,435 @@ +from __future__ import annotations + +import sys +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)) + + if len(current_line) > 0: + 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]: + # 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] == "<stdin>": + 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(t, v)) + + 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("<any>") + def _(event: KeyPressEvent) -> None: + "Disallow inserting other text." + pass + + session: PromptSession[PagerResult] = PromptSession( + merge_formatted_text( + [ + title, + HTML( + "<status-toolbar>" + "<more> -- MORE -- </more> " + "<key>[Enter]</key> Scroll " + "<key>[Space]</key> Next page " + "<key>[a]</key> Print all " + "<key>[q]</key> Quit " + "</status-toolbar>: " + ), + ] + ), + 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/ptpython/prompt_style.py index 24e5f88..96b738f 100644 --- a/ptpython/prompt_style.py +++ b/ptpython/prompt_style.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from abc import ABCMeta, abstractmethod from typing import TYPE_CHECKING @@ -16,7 +18,7 @@ class PromptStyle(metaclass=ABCMeta): @abstractmethod def in_prompt(self) -> AnyFormattedText: - " Return the input tokens. " + "Return the input tokens." return [] @abstractmethod @@ -31,7 +33,7 @@ class PromptStyle(metaclass=ABCMeta): @abstractmethod def out_prompt(self) -> AnyFormattedText: - " Return the output tokens. " + "Return the output tokens." return [] @@ -40,7 +42,7 @@ 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: diff --git a/ptpython/python_input.py b/ptpython/python_input.py index e63cdf1..54ddbef 100644 --- a/ptpython/python_input.py +++ b/ptpython/python_input.py @@ -2,12 +2,11 @@ Application for reading Python input. This can be used for creation of Python REPLs. """ -import __future__ +from __future__ import annotations -import threading -from asyncio import get_event_loop +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 ( @@ -24,9 +23,15 @@ from prompt_toolkit.completion import ( 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, @@ -44,7 +49,13 @@ from prompt_toolkit.key_binding.bindings.auto_suggest import load_auto_suggest_b 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 ( @@ -73,6 +84,11 @@ from .style import generate_style, get_all_code_styles, get_all_ui_styles 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"] @@ -80,22 +96,23 @@ if TYPE_CHECKING: 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: ... -_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. @@ -111,10 +128,10 @@ class Option(Generic[_T]): 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 @@ -122,7 +139,7 @@ class Option(Generic[_T]): 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: @@ -174,29 +191,34 @@ class PythonInput: python_input = PythonInput(...) python_code = python_input.app.run() + + :param create_app: When `False`, don't create and manage a prompt_toolkit + application. The default is `True` and should only be set + to false if PythonInput is being embedded in a separate + prompt_toolkit application. """ 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, - _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 @@ -234,7 +256,7 @@ class PythonInput: 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 [] @@ -293,7 +315,7 @@ class PythonInput: 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.) @@ -304,11 +326,23 @@ class PythonInput: 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(), } @@ -322,7 +356,7 @@ class PythonInput: ].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_ui_style_name: str = "default" @@ -340,11 +374,11 @@ class PythonInput: 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.) @@ -380,10 +414,16 @@ class PythonInput: extra_toolbars=self._extra_toolbars, ) - self.app = self._create_application(input, output) - - if vi_mode: - self.app.editing_mode = EditingMode.VI + # Create an app if requested. If not, the global get_app() is returned + # for self.app via property getter. + if create_app: + 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: + self.app.editing_mode = EditingMode.VI + else: + self._app = None def _accept_handler(self, buff: Buffer) -> bool: app = get_app() @@ -393,12 +433,12 @@ class PythonInput: @property def option_count(self) -> int: - " Return the total amount of options. (In all categories together.) " + "Return the total amount of options. (In all categories together.)" return sum(len(category.options) for category in self.options) @property - def selected_option(self) -> Option: - " Return the currently selected option. " + def selected_option(self) -> Option[Any]: + "Return the currently selected option." i = 0 for category in self.options: for o in category.options: @@ -432,24 +472,36 @@ class PythonInput: 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: """ @@ -503,7 +555,7 @@ class PythonInput: 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. """ @@ -519,15 +571,17 @@ class PythonInput: return True def simple_option( - title: str, description: str, field_name: str, values: Optional[List] = None - ) -> Option: - " Create Simple on/of option. " - values = values or ["off", "on"] - - def get_current_value(): + title: str, + description: str, + field_name: str, + values: tuple[str, str] = ("off", "on"), + ) -> Option[str]: + "Create Simple on/of option." + + 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), @@ -555,6 +609,16 @@ class PythonInput: "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.", @@ -704,10 +768,10 @@ class PythonInput: 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", @@ -778,7 +842,7 @@ class PythonInput: [ simple_option( title="Syntax highlighting", - description="Use colors for syntax highligthing", + description="Use colors for syntax highlighting", field_name="enable_syntax_highlighting", ), simple_option( @@ -799,10 +863,10 @@ class PythonInput: 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", @@ -836,8 +900,8 @@ class PythonInput: ] def _create_application( - self, input: Optional[Input], output: Optional[Output] - ) -> Application: + self, input: Input | None, output: Output | None + ) -> Application[str]: """ Create an `Application` instance. """ @@ -867,6 +931,9 @@ class PythonInput: 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, ) @@ -914,23 +981,19 @@ class PythonInput: else: self.editing_mode = EditingMode.EMACS - def _on_input_timeout(self, buff: Buffer, loop=None) -> None: + @property + def app(self) -> Application[str]: + if self._app is None: + return get_app() + return self._app + + def _on_input_timeout(self, buff: Buffer) -> None: """ When there is no input activity, in another thread, get the signature of the current code. """ - app = self.app - - # Never run multiple get-signature threads. - if self._get_signatures_thread_running: - return - self._get_signatures_thread_running = True - document = buff.document - - loop = loop or get_event_loop() - - def run(): + 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. @@ -942,26 +1005,47 @@ class PythonInput: document, self.get_locals(), self.get_globals() ) - self._get_signatures_thread_running = False + return signatures - # Set signatures and redraw if the text didn't change in the - # meantime. Otherwise request new signatures. - if buff.text == document.text: - self.signatures = signatures + app = self.app - # Set docstring in docstring buffer. - if signatures: - self.docstring_buffer.reset( - document=Document(signatures[0].docstring, cursor_position=0) + async def on_timeout_task() -> None: + loop = get_running_loop() + + # Never run multiple get-signature threads. + if self._get_signatures_thread_running: + return + self._get_signatures_thread_running = True + + try: + while True: + document = buff.document + signatures = await loop.run_in_executor( + None, get_signatures_in_executor, document ) - else: - self.docstring_buffer.reset() - app.invalidate() + # If the text didn't change in the meantime, take these + # signatures. Otherwise, try again. + if buff.text == document.text: + break + finally: + self._get_signatures_thread_running = False + + # Set signatures and redraw. + self.signatures = signatures + + # Set docstring in docstring buffer. + if signatures: + self.docstring_buffer.reset( + document=Document(signatures[0].docstring, cursor_position=0) + ) else: - self._on_input_timeout(buff, loop=loop) + self.docstring_buffer.reset() + + app.invalidate() - loop.run_in_executor(None, run) + if app.is_running: + app.create_background_task(on_timeout_task()) def on_reset(self) -> None: self.signatures = [] @@ -970,7 +1054,7 @@ class PythonInput: """ Display the history. """ - app = get_app() + app = self.app app.vi_state.input_mode = InputMode.NAVIGATION history = PythonHistory(self, self.default_buffer.document) @@ -999,6 +1083,7 @@ class PythonInput: 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. @@ -1012,43 +1097,25 @@ class PythonInput: self.app.vi_state.input_mode = InputMode.NAVIGATION # Run the UI. - result: str = "" - exception: Optional[BaseException] = None - - def in_thread() -> None: - nonlocal result, exception + while True: try: - while True: - try: - result = self.app.run(pre_run=pre_run) - - if result.lstrip().startswith("\x1a"): - # When the input starts with Ctrl-Z, quit the REPL. - # (Important for Windows users.) - raise EOFError - - # Remove leading whitespace. - # (Users can add extra indentation, which happens for - # instance because of copy/pasting code.) - result = unindent_code(result) - - if result and not result.isspace(): - return - except KeyboardInterrupt: - # Abort - try again. - self.default_buffer.document = Document() - except BaseException as e: - exception = e - return - - finally: - if self.insert_blank_line_after_input: - self.app.output.write("\n") - - thread = threading.Thread(target=in_thread) - thread.start() - thread.join() - - if exception is not None: - raise exception - return result + result = self.app.run(pre_run=pre_run, in_thread=True) + + if result.lstrip().startswith("\x1a"): + # When the input starts with Ctrl-Z, quit the REPL. + # (Important for Windows users.) + raise EOFError + + # Remove leading whitespace. + # (Users can add extra indentation, which happens for + # instance because of copy/pasting code.) + result = unindent_code(result) + + if result and not result.isspace(): + if self.insert_blank_line_after_input: + self.app.output.write("\n") + + return result + except KeyboardInterrupt: + # Abort - try again. + self.default_buffer.document = Document() diff --git a/ptpython/repl.py b/ptpython/repl.py index ae7b1d0..fc9b9da 100644 --- a/ptpython/repl.py +++ b/ptpython/repl.py @@ -7,44 +7,32 @@ Utility for creating a Python repl. embed(globals(), locals(), vi_mode=False) """ +from __future__ import annotations + import asyncio import builtins import os +import signal import sys -import threading 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 typing import Any, Callable, ContextManager, Iterable + +from prompt_toolkit.formatted_text import OneStyleAndTextTuple 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 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: @@ -53,7 +41,7 @@ except ImportError: __all__ = ["PythonRepl", "enable_deprecation_warnings", "run_config", "embed"] -def _get_coroutine_flag() -> Optional[int]: +def _get_coroutine_flag() -> int | None: for k, v in COMPILER_FLAG_NAMES.items(): if v == "COROUTINE": return k @@ -62,7 +50,7 @@ def _get_coroutine_flag() -> Optional[int]: return None -COROUTINE_FLAG: Optional[int] = _get_coroutine_flag() +COROUTINE_FLAG: int | None = _get_coroutine_flag() def _has_coroutine_flag(code: types.CodeType) -> bool: @@ -80,7 +68,7 @@ class PythonRepl(PythonInput): self._load_start_paths() def _load_start_paths(self) -> None: - " Start the Read-Eval-Print Loop. " + "Start the Read-Eval-Print Loop." if self._startup_paths: for path in self._startup_paths: if os.path.exists(path): @@ -89,7 +77,57 @@ class PythonRepl(PythonInput): exec(code, self.get_globals(), self.get_locals()) else: output = self.app.output - output.write("WARNING | File not found: {}\n\n".format(path)) + 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 BaseException as e: + self._handle_exception(e) + else: + # 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: """ @@ -102,44 +140,78 @@ class PythonRepl(PythonInput): try: while True: + # Pull text from the user. try: - # Read. - try: - text = self.read() - except EOFError: - return - - # Eval. - try: - result = self.eval(text) - except KeyboardInterrupt as e: # 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: - 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) + 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). + 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: 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 @@ -152,7 +224,7 @@ class PythonRepl(PythonInput): (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() + loop = asyncio.get_running_loop() if self.terminal_title: set_title(self.terminal_title) @@ -167,32 +239,23 @@ class PythonRepl(PythonInput): 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. - try: - result = await self.eval_async(text) - except KeyboardInterrupt as e: # 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 = [] + 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() @@ -221,7 +284,7 @@ class PythonRepl(PythonInput): result = eval(code, self.get_globals(), self.get_locals()) if _has_coroutine_flag(code): - result = asyncio.get_event_loop().run_until_complete(result) + result = asyncio.get_running_loop().run_until_complete(result) self._store_eval_result(result) return result @@ -231,7 +294,10 @@ class PythonRepl(PythonInput): # 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") - exec(code, self.get_globals(), self.get_locals()) + 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 @@ -263,21 +329,26 @@ class PythonRepl(PythonInput): self._store_eval_result(result) return result - # If not a valid `eval` expression, run using `exec` instead. + # 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") - exec(code, self.get_globals(), self.get_locals()) + 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: 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. " + "Compile code with the right compiler flags." return compile( code, "<stdin>", @@ -286,257 +357,13 @@ class PythonRepl(PythonInput): dont_inherit=True, ) - def show_result(self, result: object) -> None: - """ - Show __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. - import black - - result_repr = black.format_str( - result_repr, - mode=black.FileMode(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))] - ) - - if self.enable_pager: - self.print_paginated_formatted_text(to_formatted_text(formatted_output)) - else: - self.print_formatted_text(to_formatted_text(formatted_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 = None - - def in_thread() -> None: - nonlocal pager_result - pager_result = pager_prompt.prompt() - - th = threading.Thread(target=in_thread) - th.start() - th.join() - - 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 _handle_exception(self, e: BaseException) -> None: - output = self.app.output - - # 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] == "<stdin>": - 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)] - - print_formatted_text( - PygmentsTokens(tokens), - style=self._current_style, - style_transformation=self.style_transformation, - include_default_pygments_style=False, - output=output, + self._get_output_printer().display_exception( + e, + highlight=self.enable_syntax_highlighting, + paginate=self.enable_pager, ) - output.write("%s\n" % e) - output.flush() - def _handle_keyboard_interrupt(self, e: KeyboardInterrupt) -> None: output = self.app.output @@ -562,21 +389,16 @@ class PythonRepl(PythonInput): 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 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: @@ -590,28 +412,36 @@ def enable_deprecation_warnings() -> None: warnings.filterwarnings("default", category=DeprecationWarning, module="__main__") -def run_config(repl: PythonInput, config_file: str = "~/.ptpython/config.py") -> None: +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) + 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): - print("Impossible to read %r" % config_file) - enter_to_continue() + 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] = {} + namespace: dict[str, Any] = {} with open(config_file, "rb") as f: code = compile(f.read(), config_file, "exec") @@ -630,10 +460,10 @@ def run_config(repl: PythonInput, config_file: str = "~/.ptpython/config.py") -> def embed( globals=None, locals=None, - configure: Optional[Callable[[PythonRepl], None]] = None, + configure: Callable[[PythonRepl], None] | None = None, vi_mode: bool = False, - history_filename: Optional[str] = None, - title: Optional[str] = None, + history_filename: str | None = None, + title: str | None = None, startup_paths=None, patch_stdout: bool = False, return_asyncio_coroutine: bool = False, @@ -685,81 +515,17 @@ def embed( configure(repl) # Start repl. - patch_context: ContextManager = ( + patch_context: ContextManager[None] = ( patch_stdout_context() if patch_stdout else DummyContext() ) if return_asyncio_coroutine: - async def coroutine(): + async def coroutine() -> None: with patch_context: await repl.run_async() - return coroutine() + return coroutine() # type: ignore 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("<any>") - def _(event: KeyPressEvent) -> None: - " Disallow inserting other text. " - pass - - style - - session: PromptSession[PagerResult] = PromptSession( - merge_formatted_text( - [ - title, - HTML( - "<status-toolbar>" - "<more> -- MORE -- </more> " - "<key>[Enter]</key> Scroll " - "<key>[Space]</key> Next page " - "<key>[a]</key> Print all " - "<key>[q]</key> Quit " - "</status-toolbar>: " - ), - ] - ), - key_bindings=bindings, - erase_when_done=True, - style=style, - ) - return session diff --git a/ptpython/signatures.py b/ptpython/signatures.py index 228b99b..d4cb98c 100644 --- a/ptpython/signatures.py +++ b/ptpython/signatures.py @@ -5,16 +5,21 @@ editing. 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 +27,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 +68,9 @@ class Signature: 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 +86,7 @@ class Signature: docstring: str, signature: InspectSignature, index: int, - ) -> "Signature": + ) -> Signature: parameters = [] def get_annotation_name(annotation: object) -> str: @@ -120,7 +125,7 @@ class Signature: ) @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 +160,8 @@ class Signature: 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 +195,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/ptpython/style.py index 4b54d0c..c5a04e5 100644 --- a/ptpython/style.py +++ b/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,11 +8,11 @@ from pygments.styles import get_all_styles, get_style_by_name __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() } @@ -20,7 +20,7 @@ def get_all_code_styles() -> Dict[str, BaseStyle]: 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}. """ diff --git a/ptpython/utils.py b/ptpython/utils.py index 2fb24a4..28887d2 100644 --- a/ptpython/utils.py +++ b/ptpython/utils.py @@ -1,13 +1,24 @@ """ 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 +56,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 +81,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 +91,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 +146,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 +155,7 @@ def if_mousedown(handler: _T) -> _T: 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 +167,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/ptpython/validator.py index 0f6a4ea..91b9c28 100644 --- a/ptpython/validator.py +++ b/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 @@ class PythonValidator(Validator): # 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: diff --git a/pyproject.toml b/pyproject.toml index b356239..5421c45 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,35 @@ -[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 +[tool.ruff] +target-version = "py37" +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 +] +ignore = [ + "E501", # Line too long, handled by black + "C901", # Too complex + "E722", # bare except. +] + + +[tool.ruff.per-file-ignores] +"examples/*" = ["T201"] # Print allowed in examples. +"examples/ptpython_config/config.py" = ["F401"] # Unused imports in config. +"ptpython/entry_points/run_ptipython.py" = ["T201", "F401"] # Print, import usage. +"ptpython/entry_points/run_ptpython.py" = ["T201"] # Print usage. +"ptpython/ipython.py" = ["T100"] # Import usage. +"ptpython/repl.py" = ["T201"] # Print usage. +"ptpython/printer.py" = ["T201"] # Print usage. +"tests/run_tests.py" = ["F401"] # Unused imports. + + +[tool.ruff.isort] +known-first-party = ["ptpython"] +known-third-party = ["prompt_toolkit", "pygments", "asyncssh"] @@ -1,2 +1,41 @@ [bdist_wheel] universal=1 + +[flake8] +exclude=__init__.py +max_line_length=150 +ignore= + E114, + E116, + E117, + E121, + E122, + E123, + E125, + E126, + E127, + E128, + E131, + E171, + E203, + E211, + E221, + E227, + E231, + E241, + E251, + E301, + E402, + E501, + E701, + E702, + E704, + E731, + E741, + F401, + F403, + F405, + F811, + W503, + W504, + E722 @@ -11,24 +11,25 @@ with open(os.path.join(os.path.dirname(__file__), "README.rst")) as f: setup( name="ptpython", author="Jonathan Slenders", - version="3.0.16", + version="3.0.26", 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.16, because of the `DeduplicateCompleter`. - "prompt_toolkit>=3.0.16,<3.1.0", + # Use prompt_toolkit 3.0.34, because of `OneStyleAndTextTuple` import. + "prompt_toolkit>=3.0.34,<3.1.0", "pygments", - "black", ], - python_requires=">=3.6", + python_requires=">=3.7", classifiers=[ + "License :: OSI Approved :: BSD License", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3 :: Only", @@ -39,13 +40,18 @@ setup( "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], + "ptpython{}.{} = ptpython.entry_points.run_ptpython:run".format( + *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], + "ptipython{}.{} = ptpython.entry_points.run_ptipython:run".format( + *sys.version_info[:2] + ), ] }, - extras_require={"ptipython": ["ipython"]}, # For ptipython, we need to have IPython + extras_require={ + "ptipython": ["ipython"], # For ptipython, we need to have IPython + "all": ["black"], # Black not always possible on PyPy + }, ) diff --git a/tests/run_tests.py b/tests/run_tests.py index 2f94516..0de3743 100755 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -1,4 +1,6 @@ #!/usr/bin/env python +from __future__ import annotations + import unittest import ptpython.completer |