summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/test.yaml8
-rw-r--r--CHANGELOG125
-rw-r--r--LICENSE2
-rw-r--r--README.rst58
-rw-r--r--debian/changelog115
-rw-r--r--debian/control5
-rw-r--r--debian/copyright4
-rw-r--r--debian/patches/debian/0001-platformdirs.patch53
-rw-r--r--debian/patches/series1
-rwxr-xr-xdebian/rules9
-rw-r--r--debian/watch3
-rw-r--r--docs/concurrency-challenges.rst2
-rwxr-xr-xexamples/asyncio-python-embed.py15
-rwxr-xr-xexamples/asyncio-ssh-python-embed.py18
-rw-r--r--examples/ptpython_config/config.py15
-rwxr-xr-xexamples/python-embed-with-custom-prompt.py12
-rwxr-xr-xexamples/python-embed.py2
-rwxr-xr-xexamples/ssh-and-telnet-embed.py11
-rw-r--r--ptpython/__init__.py2
-rw-r--r--ptpython/__main__.py2
-rw-r--r--ptpython/completer.py105
-rw-r--r--ptpython/contrib/asyncssh_repl.py32
-rw-r--r--ptpython/entry_points/run_ptipython.py6
-rw-r--r--ptpython/entry_points/run_ptpython.py43
-rw-r--r--ptpython/eventloop.py16
-rw-r--r--ptpython/filters.py5
-rw-r--r--ptpython/history_browser.py147
-rw-r--r--ptpython/ipython.py80
-rw-r--r--ptpython/key_bindings.py77
-rw-r--r--ptpython/layout.py108
-rw-r--r--ptpython/lexer.py6
-rw-r--r--ptpython/printer.py435
-rw-r--r--ptpython/prompt_style.py8
-rw-r--r--ptpython/python_input.py329
-rw-r--r--ptpython/repl.py608
-rw-r--r--ptpython/signatures.py28
-rw-r--r--ptpython/style.py8
-rw-r--r--ptpython/utils.py28
-rw-r--r--ptpython/validator.py11
-rw-r--r--pyproject.toml48
-rw-r--r--setup.cfg39
-rw-r--r--setup.py28
-rwxr-xr-xtests/run_tests.py2
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
diff --git a/CHANGELOG b/CHANGELOG
index 67ac0a8..d873862 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -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:
diff --git a/LICENSE b/LICENSE
index 910b80a..89a5114 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2015, Jonathan Slenders
+Copyright (c) 2015-2023, Jonathan Slenders
All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
diff --git a/README.rst b/README.rst
index ae12f4d..8ec9aca 100644
--- a/README.rst
+++ b/README.rst
@@ -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"]
diff --git a/setup.cfg b/setup.cfg
index 3c6e79c..80dfec6 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -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
diff --git a/setup.py b/setup.py
index dbbe55b..a54da35 100644
--- a/setup.py
+++ b/setup.py
@@ -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