summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--.github/workflows/test.yaml38
-rw-r--r--.gitignore54
-rw-r--r--CHANGELOG692
-rw-r--r--LICENSE27
-rw-r--r--MANIFEST.in3
-rw-r--r--README.rst299
-rw-r--r--docs/concurrency-challenges.rst91
-rw-r--r--docs/images/example1.pngbin0 -> 28700 bytes
-rw-r--r--docs/images/file-completion.pngbin0 -> 37364 bytes
-rw-r--r--docs/images/ipython.pngbin0 -> 36967 bytes
-rw-r--r--docs/images/multiline.pngbin0 -> 33129 bytes
-rw-r--r--docs/images/ptpython-history-help.pngbin0 -> 103337 bytes
-rw-r--r--docs/images/ptpython-menu.pngbin0 -> 60225 bytes
-rw-r--r--docs/images/ptpython.pngbin0 -> 28700 bytes
-rw-r--r--docs/images/validation.pngbin0 -> 17124 bytes
-rw-r--r--docs/images/windows.pngbin0 -> 18368 bytes
-rwxr-xr-xexamples/asyncio-python-embed.py53
-rwxr-xr-xexamples/asyncio-ssh-python-embed.py56
-rw-r--r--examples/ptpython_config/config.py207
-rwxr-xr-xexamples/python-embed-with-custom-prompt.py38
-rwxr-xr-xexamples/python-embed.py12
-rwxr-xr-xexamples/python-input.py15
-rwxr-xr-xexamples/ssh-and-telnet-embed.py52
-rw-r--r--examples/test-cases/ptpython-in-other-thread.py24
-rw-r--r--mypy.ini6
-rw-r--r--ptpython/__init__.py5
-rw-r--r--ptpython/__main__.py8
-rw-r--r--ptpython/completer.py671
-rw-r--r--ptpython/contrib/__init__.py0
-rw-r--r--ptpython/contrib/asyncssh_repl.py125
-rw-r--r--ptpython/entry_points/__init__.py0
-rw-r--r--ptpython/entry_points/run_ptipython.py82
-rw-r--r--ptpython/entry_points/run_ptpython.py234
-rw-r--r--ptpython/eventloop.py75
-rw-r--r--ptpython/filters.py39
-rw-r--r--ptpython/history_browser.py687
-rw-r--r--ptpython/ipython.py339
-rw-r--r--ptpython/key_bindings.py337
-rw-r--r--ptpython/layout.py773
-rw-r--r--ptpython/lexer.py30
-rw-r--r--ptpython/printer.py435
-rw-r--r--ptpython/prompt_style.py79
-rw-r--r--ptpython/py.typed0
-rw-r--r--ptpython/python_input.py1121
-rw-r--r--ptpython/repl.py531
-rw-r--r--ptpython/signatures.py270
-rw-r--r--ptpython/style.py175
-rw-r--r--ptpython/utils.py212
-rw-r--r--ptpython/validator.py62
-rw-r--r--pyproject.toml35
-rw-r--r--setup.cfg41
-rw-r--r--setup.py57
-rwxr-xr-xtests/run_tests.py24
53 files changed, 8114 insertions, 0 deletions
diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
new file mode 100644
index 0000000..9a50f3b
--- /dev/null
+++ b/.github/workflows/test.yaml
@@ -0,0 +1,38 @@
+name: test
+
+on:
+ push: # any branch
+ pull_request:
+ branches: [master]
+
+jobs:
+ test-ubuntu:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ python-version: [3.7, 3.8, 3.9, "3.10", "3.11"]
+
+ steps:
+ - uses: actions/checkout@v2
+ - name: Setup Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v1
+ with:
+ python-version: ${{ matrix.python-version }}
+ - name: Install Dependencies
+ run: |
+ sudo apt remove python3-pip
+ python -m pip install --upgrade pip
+ python -m pip install . ruff mypy pytest readme_renderer
+ pip list
+ - name: Type Checker
+ run: |
+ mypy ptpython
+ ruff .
+ ruff format --check .
+ - name: Run Tests
+ run: |
+ ./tests/run_tests.py
+ - name: Validate README.md
+ # Ensure that the README renders correctly (required for uploading to PyPI).
+ run: |
+ python -m readme_renderer README.rst > /dev/null
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..db4561e
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,54 @@
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+env/
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+*.egg-info/
+.installed.cfg
+*.egg
+
+# PyInstaller
+# Usually these files are written by a python script from a template
+# before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.coverage
+.cache
+nosetests.xml
+coverage.xml
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+target/
diff --git a/CHANGELOG b/CHANGELOG
new file mode 100644
index 0000000..d873862
--- /dev/null
+++ b/CHANGELOG
@@ -0,0 +1,692 @@
+CHANGELOG
+=========
+
+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.)
+
+Fixes:
+- Several fixes to the completion code:
+ * Give dictionary completions priority over path completions.
+ * Always call non-fuzzy completer after fuzzy completer to prevent that some
+ completions were missed out if the fuzzy completer doesn't find them.
+
+
+3.0.15: 2021-02-11
+------------------
+
+New features:
+- When pressing control-w, only delete characters until a punctuation.
+
+Fixes:
+- Fix `AttributeError` during retrieval of signatures with type annotations.
+
+
+3.0.14: 2021-02-10
+------------------
+
+New features:
+- Display of signature and completion drop down together.
+- If `DictionaryCompleter` is enabled, also retrieve signatures when Jedi
+ fails, using the same logic.
+- List function parameters first and private attributes at the end in the
+ completion menu.
+- Cleanup of the completion code.
+
+Fixes:
+- Handle exceptions raised when `repr()` is called.
+- Fix leakage of `exc_info` from eval to exec call.
+- Fix handling of `KeyboardInterrupt` in REPL during evaluation of `__repr__`.
+- Fix style for signature toolbar.
+- Hide signature when sidebar is visible.
+
+
+3.0.13: 2021-01-26
+------------------
+
+New features:
+- Added 'print all' option to pager.
+- Improve handling of indented code:
+ * Allow multiline input to be indented as a whole (we will unindent before
+ executing).
+ * Correctly visualize tabs (instead of ^I, which happens when pasted in
+ bracketed paste).
+
+Fixes:
+- Fix line ending bug in pager.
+
+
+3.0.12: 2021-01-24
+------------------
+
+New features:
+- Expose a `get_ptpython` function in the global namespace, to get programmatic
+ access to the REPL.
+- Expose `embed()` at the top level of the package. Make it possible to do
+ `from ptpython import embed`.
+
+Fixes:
+- Properly handle exceptions when trying to access `__pt_repr__`.
+- Properly handle `SystemExit`.
+
+
+3.0.11: 2021-01-20
+------------------
+
+New features:
+- Add support for top-level await.
+- Refactoring of event loop usage:
+
+ * The ptpython input UI will now run in a separate thread. This makes it
+ possible to properly embed ptpython in an asyncio application, without
+ having to deal with nested event loops (which asyncio does not support).
+
+ * The "eval" part doesn't anymore take place within a ptpython coroutine, so
+ it can spawn its own loop if needed. This also fixes `asyncio.run()` usage
+ in the REPL, which was broken before.
+
+- Added syntax highlighting and autocompletion for !-style system commands.
+
+Fixes:
+- Remove unexpected additional line after output.
+- Fix system prompt. Accept !-style inputs again.
+- Don't execute PYTHONSTARTUP when -i flag was given.
+
+
+3.0.10: 2021-01-13
+------------------
+
+Fixes:
+- Do dictionary completion on Sequence and Mapping objects (from
+ collections.abc). Note that dictionary completion is still turned off by
+ default.
+
+
+3.0.9: 2021-01-10
+-----------------
+
+New features:
+- Allow replacing `PythonInput.completer` at runtime (useful for tools build on
+ top of ptpython).
+- Show REPL title in pager.
+
+
+3.0.8: 2021-01-05
+-----------------
+
+New features:
+- Optional output formatting using Black.
+- 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.
+- Add `PTPYTHON_CONFIG_HOME` for explicitly setting the config directory.
+- Show completion suffixes (like '(' for functions).
+
+Fixes:
+- Fix dictionary completion on Pandas objects.
+- Stop using deprecated Jedi functions.
+
+
+3.0.7: 2020-09-25
+-----------------
+
+New features:
+- Option to show/hide private attributes during a completion
+- Added `insert_blank_line_after_input` option similar to
+ `insert_blank_line_after_output`.
+
+Fixes:
+- Fixed some formatting issues of `__pt_repr__`.
+- Abbreviate completion meta information for dictionary completer if needed.
+
+
+3.0.6: 2020-09-23
+-----------------
+
+New features:
+- (Experimental) support for `__pt_repr__` methods. If objects implement this
+ method, this will be used to print the result in the REPL instead of the
+ normal `__repr__`.
+- Added py.typed file, to enable type checking for applications that are
+ embedding ptpython.
+
+
+3.0.5: 2020-08-10
+-----------------
+
+Fixes:
+- Handle bug in dictionary completion when numeric keys are used.
+
+
+3.0.4: 2020-08-10
+-----------------
+
+New features:
+- Allow leading whitespace before single line expressions.
+- Show full syntax error in validator.
+- Added `vi_start_in_navigation_mode` and `vi_keep_last_used_mode` options.
+
+Fixes:
+- Improved dictionary completion: handle keys that contain spaces and don't
+ recognize numbers as variable names.
+- Fix in exit confirmation.
+
+
+3.0.3: 2020-07-10
+-----------------
+
+Fixes:
+- Sort attribute names for `DictionaryCompleter` and move underscored
+ attributes to the end.
+- Handle unhandled exceptions in `get_compiler_flags`.
+- Improved `run_async` code.
+- Fix --version parameter.
+
+
+3.0.2: 2020-04-14
+-----------------
+
+New features:
+- Improved custom dictionary completion:
+ * Also complete list indexes.
+ * Also complete attributes after doing a dictionary lookup.
+ * Also complete iterators in a for-loop.
+- Added a 'title' option, so that applications embedding ptpython can set a
+ title in the status bar.
+
+
+3.0.1: 2020-02-24
+-----------------
+
+- Fix backwards-compatibility of the `run_config` function. (used by
+ django-extensions).
+- Fix input mode in status bar for block selection.
+
+
+3.0.0: 2020-01-29
+-----------------
+
+Upgrade to prompt_toolkit 3.0.
+Requires at least Python 3.6.
+
+New features:
+- Uses XDG base directory specification.
+
+
+2.0.5: 2019-10-09
+-----------------
+
+New features:
+- Added dictionary completer (off by default).
+- Added fuzzy completion (off by default).
+- Highlight keywords in completion dropdown menu.
+- Enable universal wheels.
+
+Fixes:
+- Fixed embedding repl as asyncio coroutine.
+- Fixed patching stdout in embedded repl.
+- Fixed ResourceWarning in setup.py.
+
+
+2.0.4: 2018-10-30
+-----------------
+
+- Fixed ptipython.
+- Fixed config: setting of color depth.
+- Fixed auto-suggest key bindings.
+- Fixed Control-D key binding for exiting REPL when (confirm_exit=False).
+- Correctly focus/unfocus sidebar.
+- Fixed open_in_editor and suspend key bindings.
+
+
+2.0.3: 2018-10-12
+-----------------
+
+- Allow changing the min/max brightness.
+- Some changes for compatibility with the latest prompt_toolkit.
+
+2.0.2: 2018-09-30
+-----------------
+
+Fixes:
+- Don't crash the history browser when there was no history.
+- Set last exception in the sys module, when an exception was raised.
+- Require prompt_toolkit 2.0.5.
+
+
+2.0.1: 2018-09-30
+-----------------
+
+Upgrade to prompt_toolkit 2.0.x.
+
+
+0.36: 2016-10-16
+----------------
+
+New features:
+- Support for editing in Vi block mode. (Only enabled for
+ prompt_toolkit>=1.0.8.)
+
+Fixes:
+- Handle two Jedi crashes. (GitHub ptpython issues #136 and #91.)
+
+0.35: 2016-07-19
+----------------
+
+Fixes:
+- Fix in completer. Don't hang when pasting a long string with many
+ backslashes.
+- Fix Python2 bug: crash when filenames contain non-ascii characters.
+- Added `pt[i]pythonX` and `pt[i]pythonX.X` commands.
+- Compatibility with IPython 5.0.
+
+
+0.34: 2016-05-06
+---------------
+
+Bugfix in ptipython: reset input buffer before every read in run().
+
+
+0.33: 2016-05-05
+---------------
+
+Upgrade to prompt_toolkit 1.0.0
+
+Improvements:
+- Unindent after typing 'pass'.
+- Make it configurable whether or not a blank line has to be inserted after the output.
+
+
+0.32: 2016-03-29
+---------------
+
+Fixes:
+- Fixed bug when PYTHONSTARTUP was not found.
+- Support $PYTHONSTARTUP for ptipython.
+
+
+0.31: 2016-03-14
+---------------
+
+Upgrade to prompt_toolkit 0.60
+
+
+0.30: 2016-02-27
+---------------
+
+Upgrade to prompt_toolkit 0.59
+
+
+0.29: 2016-02-24
+----------------
+
+Upgrade to prompt_toolkit 0.58
+
+New features:
+- Improved mouse support
+
+
+0.28: 2016-01-04
+----------------
+
+Upgrade to prompt_toolkit 0.57
+
+
+0.27: 2016-01-03
+----------------
+
+Upgrade to prompt_toolkit 0.56
+
+
+0.26: 2016-01-03
+----------------
+
+Upgrade to prompt_toolkit 0.55
+
+Fixes:
+- Handle several bugs in Jedi.
+- Correctly handle sys.argv when pt(i)python is started with --interactive.
+- Support for 24bit true color.
+- Take compiler flags into account for ptipython.
+
+
+0.25: 2015-10-29
+----------------
+
+Upgrade to prompt_toolkit 0.54
+
+Fixes:
+- Consider input multiline when there's a colon at the end of the line.
+- Handle bug in Jedi.
+- Enable search bindings in history browser.
+
+
+0.24: 2015-09-24
+----------------
+
+Upgrade to prompt_toolkit 0.52
+
+
+0.23: 2015-09-24
+----------------
+
+Upgrade to prompt_toolkit 0.51
+
+New features:
+- Mouse support
+- Fish style auto suggestion.
+- Optionally disabling of line wraps.
+- Use Python3Lexer for Python 3.
+
+
+0.22: 2015-09-06
+----------------
+
+Upgrade to prompt_toolkit 0.50
+
+Fixes:
+- Correctly accept file parameter in the print function of
+ asyncssh_repl.ReplSSHServerSession.
+- Create config directory if it doesn't exist yet (For IPython entry point.)
+
+New features:
+- Implementation of history-selection: a tool to select lines from the history.
+- Make exit message configurable.
+- Improve start-up time: Lazy load completer grammar and lazy-import Jedi.
+- Make multi-column the default completion visualisation.
+- Implementation of a custom prompts. In_tokens and out_tokens can be
+ customized.
+- Made an option to show/hide highlighting for matching parenthesis.
+- Some styling improvements.
+
+
+0.21: 2015-08-08
+---------------
+
+Upgrade to prompt_toolkit 0.46
+
+Fixes:
+- Correctly add current directory to sys.path.
+- Only show search highlighting when the search is the current input buffer.
+- Styling fix.
+
+
+0.20: 2015-07-30
+---------------
+
+Upgrade to prompt_toolkit 0.45
+
+
+0.19: 2015-07-30
+---------------
+
+Upgrade to prompt_toolkit 0.44
+
+New features:
+- Added --interactive option for ptipython.
+- A few style improvements.
+
+
+0.18: 2015-07-15
+---------------
+
+Fixes:
+- Python 2.6 compatibility.
+
+
+0.17: 2015-07-15
+---------------
+
+Upgrade to prompt_toolkit 0.43
+
+New features:
+- Integration with Tk eventloop. (This makes turtle and other Tk stuff work
+ again from the REPL.)
+- Multi column completion visualisation.
+
+
+0.16: 2015-06-25
+---------------
+
+Upgrade to prompt_toolkit 0.42
+
+Fixes:
+- Workaround for Jedi bug. (Signatures of functions with keyword-only arguments.)
+- Correctly show traceback on Python 3.
+- Better styling of the options sidebar.
+
+New features:
+- Exit REPL when input starts with Control-Z.
+- Set terminal title.
+- Display help text in options sidebar.
+- Better colorscheme for Windows.
+
+
+0.15: 2015-06-20
+---------------
+
+Upgrade to prompt_toolkit 0.41
+
+Fixes:
+- Correct tokens for IPython prompt.
+- Syntax fix in asyncssh_repl.
+
+
+0.14: 2015-06-16
+---------------
+
+Fix:
+- Correct dependency for prompt_toolkit.
+
+0.13: 2015-06-15
+---------------
+
+New features:
+- Upgrade to prompt_toolkit 0.40
+- Options sidebar.
+- Custom color schemes.
+- Syntax highlighting of the output.
+- Input validation can now be turned off.
+- Determine compiler flags dynamically. (Fixes importing unicode_literals).
+- Exit confirmation and dialog.
+- Autocompletion of IPython %cat command.
+- Correctly render tracebacks on Windows.
+
+0.12: 2015-06-04
+---------------
+
+Upgrade to prompt_toolkit 0.39
+
+0.11: 2015-05-31
+---------------
+
+New features:
+- Upgrade to prompt-toolkit 0.38.
+- Upgrade to Jedi 0.9.0
+- Fixed default globals for repl (correct __name, __builtins__, etc...)
+- Display deprecation warnings in the REPL.
+- Added configuration support.
+- Added asyncio-ssh-python-embed example.
+
+
+0.10: 2015-05-11
+---------------
+
+Upgrade to prompt-toolkit 0.37.
+
+
+0.9: 2015-05-07
+---------------
+
+Upgrade to prompt-toolkit 0.35.
+
+
+0.8: 2015-04-26
+---------------
+
+Fixed:
+- eval() doesn't run using unicode_literals anymore.
+- Upgrade to prompt-toolkit 0.34.
+
+
+0.7: 2015-04-25
+---------------
+
+Fixed:
+- Upgrade to prompt-toolkit 0.33.
+
+New features:
+- Added complete_while_typing option.
+
+
+0.6: 2015-04-22
+---------------
+
+Fixed:
+- Upgrade to prompt-toolkit 0.32 which has many new features.
+
+Changes:
+- Removal of tab pages + clean up.
+- Pressing enter twice will now always automatically execute the input.
+- Improved Control-D key binding.
+- Hide docstring by default.
+
+
+0.5: 2015-01-30
+---------------
+
+Fixed:
+- Tab autocompletion on first line.
+- Upgrade to prompt-toolkit 0.31
+
+New features:
+- Simple autocompletion for IPython magics.
+
+0.4: 2015-01-26
+---------------
+
+Fixed:
+- Upgrade to prompt-toolkit 0.30
+
+0.3: 2015-01-25
+---------------
+
+Fixed:
+- Upgrade to prompt-toolkit 0.28
+
+0.2: 2015-01-25
+---------------
+
+Moved ptpython code from prompt-toolkit inside this repository.
+
+0.1: 2014-09-29
+---------------
+
+Initial ptpython version. (Source code was still in the
+prompt-toolkit repository itself.)
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..89a5114
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,27 @@
+Copyright (c) 2015-2023, Jonathan Slenders
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification,
+are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+
+* Redistributions in binary form must reproduce the above copyright notice, this
+ list of conditions and the following disclaimer in the documentation and/or
+ other materials provided with the distribution.
+
+* Neither the name of the {organization} nor the names of its
+ contributors may be used to endorse or promote products derived from
+ this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 0000000..b010432
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,3 @@
+include *rst LICENSE CHANGELOG MANIFEST.in
+recursive-include examples *.py
+prune examples/sample?/build
diff --git a/README.rst b/README.rst
new file mode 100644
index 0000000..8ec9aca
--- /dev/null
+++ b/README.rst
@@ -0,0 +1,299 @@
+ptpython
+========
+
+|Build Status| |PyPI| |License|
+
+*A better Python REPL*
+
+::
+
+ pip install ptpython
+
+.. image :: https://github.com/jonathanslenders/ptpython/raw/master/docs/images/example1.png
+
+Ptpython is an advanced Python REPL. It should work on all
+Python versions from 2.6 up to 3.9 and work cross platform (Linux,
+BSD, OS X and Windows).
+
+Note: this version of ptpython requires at least Python 3.6. Install ptpython
+2.0.5 for older Python versions.
+
+
+Installation
+************
+
+Install it using pip:
+
+::
+
+ pip install ptpython
+
+Start it by typing ``ptpython``.
+
+
+Features
+********
+
+- Syntax highlighting.
+- Multiline editing (the up arrow works).
+- Autocompletion.
+- Mouse support. [1]
+- Support for color schemes.
+- Support for `bracketed paste <https://cirw.in/blog/bracketed-paste>`_ [2].
+- Both Vi and Emacs key bindings.
+- Support for double width (Chinese) characters.
+- ... and many other things.
+
+
+[1] Disabled by default. (Enable in the menu.)
+
+[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
+*************************************
+
+When classes implement a ``__pt_repr__`` method, this will be used instead of
+``__repr__`` for printing. Any `prompt_toolkit "formatted text"
+<https://python-prompt-toolkit.readthedocs.io/en/master/pages/printing_text.html>`_
+can be returned from here. In order to avoid writing a ``__repr__`` as well,
+the ``ptpython.utils.ptrepr_to_repr`` decorator can be applied. For instance:
+
+.. code:: python
+
+ from ptpython.utils import ptrepr_to_repr
+ from prompt_toolkit.formatted_text import HTML
+
+ @ptrepr_to_repr
+ class MyClass:
+ def __pt_repr__(self):
+ return HTML('<yellow>Hello world!</yellow>')
+
+More screenshots
+****************
+
+The configuration menu:
+
+.. image :: https://github.com/jonathanslenders/ptpython/raw/master/docs/images/ptpython-menu.png
+
+The history page and its help:
+
+.. image :: https://github.com/jonathanslenders/ptpython/raw/master/docs/images/ptpython-history-help.png
+
+Autocompletion:
+
+.. image :: https://github.com/jonathanslenders/ptpython/raw/master/docs/images/file-completion.png
+
+
+Embedding the REPL
+******************
+
+Embedding the REPL in any Python application is easy:
+
+.. code:: python
+
+ from ptpython.repl import embed
+ embed(globals(), locals())
+
+You can make ptpython your default Python REPL by creating a `PYTHONSTARTUP file
+<https://docs.python.org/3/tutorial/appendix.html#the-interactive-startup-file>`_ containing code
+like this:
+
+.. code:: python
+
+ import sys
+ try:
+ from ptpython.repl import embed
+ except ImportError:
+ print("ptpython is not available: falling back to standard prompt")
+ 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
+*****************
+
+Multi-line editing mode will automatically turn on when you press enter after a
+colon.
+
+To execute the input in multi-line mode, you can either press ``Alt+Enter``, or
+``Esc`` followed by ``Enter``. (If you want the first to work in the OS X
+terminal, you have to check the "Use option as meta key" checkbox in your
+terminal settings. For iTerm2, you have to check "Left option acts as +Esc" in
+the options.)
+
+.. image :: https://github.com/jonathanslenders/ptpython/raw/master/docs/images/multiline.png
+
+
+Syntax validation
+*****************
+
+Before execution, ``ptpython`` will see whether the input is syntactically
+correct Python code. If not, it will show a warning, and move the cursor to the
+error.
+
+.. image :: https://github.com/jonathanslenders/ptpython/raw/master/docs/images/validation.png
+
+
+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
+*******************
+
+Running system commands: Press ``Meta-!`` in Emacs mode or just ``!`` in Vi
+navigation mode to see the "Shell command" prompt. There you can enter system
+commands without leaving the REPL.
+
+Selecting text: Press ``Control+Space`` in Emacs mode or ``V`` (major V) in Vi
+navigation mode.
+
+
+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
+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.
+
+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
+***************
+
+Run ``ptipython`` (prompt_toolkit - IPython), to get a nice interactive shell
+with all the power that IPython has to offer, like magic functions and shell
+integration. Make sure that IPython has been installed. (``pip install
+ipython``)
+
+.. image :: https://github.com/jonathanslenders/ptpython/raw/master/docs/images/ipython.png
+
+This is also available for embedding:
+
+.. code:: python
+
+ from ptpython.ipython import embed
+ embed(globals(), locals())
+
+
+Django support
+**************
+
+`django-extensions <https://github.com/django-extensions/django-extensions>`_
+has a ``shell_plus`` management command. When ``ptpython`` has been installed,
+it will by default use ``ptpython`` or ``ptipython``.
+
+
+PDB
+***
+
+There is an experimental PDB replacement: `ptpdb
+<https://github.com/jonathanslenders/ptpdb>`_.
+
+
+Windows support
+***************
+
+``prompt_toolkit`` and ``ptpython`` works better on Linux and OS X than on
+Windows. Some things might not work, but it is usable:
+
+.. image :: https://github.com/jonathanslenders/ptpython/raw/master/docs/images/windows.png
+
+
+FAQ
+***
+
+**Q**: The ``Ctrl-S`` forward search doesn't work and freezes my terminal.
+
+**A**: Try to run ``stty -ixon`` in your terminal to disable flow control.
+
+**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
+can also type ``Escape`` before any key instead.
+
+
+Alternatives
+************
+
+- `BPython <http://bpython-interpreter.org/downloads.html>`_
+- `IPython <https://ipython.org/>`_
+
+If you find another alternative, you can create an issue and we'll list it
+here. If you find a nice feature somewhere that is missing in ``ptpython``,
+also create a GitHub issue and maybe we'll implement it.
+
+
+Special thanks to
+*****************
+
+- `Pygments <http://pygments.org/>`_: Syntax highlighter.
+- `Jedi <http://jedi.jedidjah.ch/en/latest/>`_: Autocompletion library.
+- `wcwidth <https://github.com/jquast/wcwidth>`_: Determine columns needed for a wide characters.
+- `prompt_toolkit <http://github.com/jonathanslenders/python-prompt-toolkit>`_ for the interface.
+
+.. |Build Status| image:: https://api.travis-ci.org/prompt-toolkit/ptpython.svg?branch=master
+ :target: https://travis-ci.org/prompt-toolkit/ptpython#
+
+.. |License| image:: https://img.shields.io/github/license/prompt-toolkit/ptpython.svg
+ :target: https://github.com/prompt-toolkit/ptpython/blob/master/LICENSE
+
+.. |PyPI| image:: https://pypip.in/version/ptpython/badge.svg
+ :target: https://pypi.python.org/pypi/ptpython/
+ :alt: Latest Version
diff --git a/docs/concurrency-challenges.rst b/docs/concurrency-challenges.rst
new file mode 100644
index 0000000..0ff9c6c
--- /dev/null
+++ b/docs/concurrency-challenges.rst
@@ -0,0 +1,91 @@
+
+Concurrency-related challenges regarding embedding of ptpython in asyncio code
+==============================================================================
+
+Things we want to be possible
+-----------------------------
+
+- Embed blocking ptpython in non-asyncio code (the normal use case).
+- Embed blocking ptpython in asyncio code (the event loop will block).
+- Embed awaitable ptpython in asyncio code (the loop will continue).
+- React to resize events (SIGWINCH).
+- Support top-level await.
+- Be able to patch_stdout, so that logging messages from another thread will be
+ printed above the prompt.
+- It should be possible to handle `KeyboardInterrupt` during evaluation of an
+ expression.
+- The "eval" should happen in the same thread from where embed() was called.
+
+
+Limitations of asyncio/python
+-----------------------------
+
+- We can only listen to SIGWINCH signal (resize) events in the main thread.
+
+- Usage of Control-C for triggering a `KeyboardInterrupt` only works for code
+ running in the main thread. (And only if the terminal was not set in raw
+ input mode).
+
+- Spawning a new event loop from within a coroutine, that's being executed in
+ an existing event loop is not allowed in asyncio. We can however spawn any
+ event loop in a separate thread, and wait for that thread to finish.
+
+- For patch_stdout to work correctly, we have to know what prompt_toolkit
+ application is running on the terminal, then tell that application to print
+ the output and redraw itself.
+
+
+Additional challenges for IPython
+---------------------------------
+
+IPython supports integration of 3rd party event loops (for various GUI
+toolkits). These event loops are supposed to continue running while we are
+prompting for input. In an asyncio environment, it means that there are
+situations where we have to juggle three event loops:
+
+- The asyncio loop in which the code was embedded.
+- The asyncio loop from the prompt.
+- The 3rd party GUI loop.
+
+Approach taken in ptpython 3.0.11
+---------------------------------
+
+For ptpython, the most reliable solution is to to run the prompt_toolkit input
+prompt in a separate background thread. This way it can use its own asyncio
+event loop without ever having to interfere with whatever runs in the main
+thread.
+
+Then, depending on how we embed, we do the following:
+When a normal blocking embed is used:
+ * We start the UI thread for the input, and do a blocking wait on
+ `thread.join()` here.
+ * The "eval" happens in the main thread.
+ * The "print" happens also in the main thread. Unless a pager is shown,
+ which is also a prompt_toolkit application, then the pager itself is runs
+ also in another thread, similar to the way we do the input.
+
+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_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.
+
+This means that the prompt_toolkit application code will always run in a
+different thread. It means it won't be able to respond to SIGWINCH (window
+resize events), but prompt_toolkit's 3.0.11 has now terminal size polling which
+solves this.
+
+Control-C key presses won't interrupt the main thread while we wait for input,
+because the prompt_toolkit application turns the terminal in raw mode, while
+it's reading, which means that it will receive control-c key presses as raw
+data in its own thread.
+
+Top-level await works in most situations as expected.
+- If a blocking embed is used. We execute ``loop.run_until_complete(code)``.
+ This assumes that the blocking embed is not used in a coroutine of a running
+ event loop, otherwise, this will attempt to start a nested event loop, which
+ asyncio does not support. In that case we will get an exception.
+- If an awaitable embed is used. We literally execute ``await code``. This will
+ integrate nicely in the current event loop.
diff --git a/docs/images/example1.png b/docs/images/example1.png
new file mode 100644
index 0000000..c46b55a
--- /dev/null
+++ b/docs/images/example1.png
Binary files differ
diff --git a/docs/images/file-completion.png b/docs/images/file-completion.png
new file mode 100644
index 0000000..3143293
--- /dev/null
+++ b/docs/images/file-completion.png
Binary files differ
diff --git a/docs/images/ipython.png b/docs/images/ipython.png
new file mode 100644
index 0000000..4ee77de
--- /dev/null
+++ b/docs/images/ipython.png
Binary files differ
diff --git a/docs/images/multiline.png b/docs/images/multiline.png
new file mode 100644
index 0000000..cdfeb32
--- /dev/null
+++ b/docs/images/multiline.png
Binary files differ
diff --git a/docs/images/ptpython-history-help.png b/docs/images/ptpython-history-help.png
new file mode 100644
index 0000000..a52b5c2
--- /dev/null
+++ b/docs/images/ptpython-history-help.png
Binary files differ
diff --git a/docs/images/ptpython-menu.png b/docs/images/ptpython-menu.png
new file mode 100644
index 0000000..923ca12
--- /dev/null
+++ b/docs/images/ptpython-menu.png
Binary files differ
diff --git a/docs/images/ptpython.png b/docs/images/ptpython.png
new file mode 100644
index 0000000..c46b55a
--- /dev/null
+++ b/docs/images/ptpython.png
Binary files differ
diff --git a/docs/images/validation.png b/docs/images/validation.png
new file mode 100644
index 0000000..cce14a1
--- /dev/null
+++ b/docs/images/validation.png
Binary files differ
diff --git a/docs/images/windows.png b/docs/images/windows.png
new file mode 100644
index 0000000..188d698
--- /dev/null
+++ b/docs/images/windows.png
Binary files differ
diff --git a/examples/asyncio-python-embed.py b/examples/asyncio-python-embed.py
new file mode 100755
index 0000000..a8fbba5
--- /dev/null
+++ b/examples/asyncio-python-embed.py
@@ -0,0 +1,53 @@
+#!/usr/bin/env python
+"""
+(Python >3.3)
+
+This is an example of how we can embed a Python REPL into an asyncio
+application. In this example, we have one coroutine that runs in the
+background, prints some output and alters a global state. The REPL, which runs
+inside another coroutine can access and change this global state, interacting
+with the running asyncio application.
+The ``patch_stdout`` option makes sure that when another coroutine is writing
+to stdout, it won't break the input line, but instead writes nicely above the
+prompt.
+"""
+import asyncio
+
+from ptpython.repl import embed
+
+loop = asyncio.get_event_loop()
+counter = [0]
+
+
+async def print_counter() -> None:
+ """
+ Coroutine that prints counters and saves it in a global variable.
+ """
+ while True:
+ print("Counter: %i" % counter[0])
+ counter[0] += 1
+ await asyncio.sleep(3)
+
+
+async def interactive_shell() -> None:
+ """
+ Coroutine that starts a Python REPL from which we can access the global
+ counter variable.
+ """
+ print(
+ 'You should be able to read and update the "counter[0]" variable from this shell.'
+ )
+ try:
+ await embed(globals=globals(), return_asyncio_coroutine=True, patch_stdout=True)
+ except EOFError:
+ # Stop the loop when quitting the repl. (Ctrl-D press.)
+ loop.stop()
+
+
+async def main() -> None:
+ asyncio.create_task(print_counter())
+ await interactive_shell()
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/examples/asyncio-ssh-python-embed.py b/examples/asyncio-ssh-python-embed.py
new file mode 100755
index 0000000..be0689e
--- /dev/null
+++ b/examples/asyncio-ssh-python-embed.py
@@ -0,0 +1,56 @@
+#!/usr/bin/env python
+"""
+Example of running the Python REPL through an SSH connection in an asyncio process.
+This requires Python 3, asyncio and asyncssh.
+
+Run this example and then SSH to localhost, port 8222.
+"""
+import asyncio
+import logging
+
+import asyncssh
+
+from ptpython.contrib.asyncssh_repl import ReplSSHServerSession
+
+logging.basicConfig()
+logging.getLogger().setLevel(logging.INFO)
+
+
+class MySSHServer(asyncssh.SSHServer):
+ """
+ Server without authentication, running `ReplSSHServerSession`.
+ """
+
+ def __init__(self, get_namespace):
+ self.get_namespace = get_namespace
+
+ def begin_auth(self, username):
+ # No authentication.
+ return False
+
+ def session_requested(self):
+ return ReplSSHServerSession(self.get_namespace)
+
+
+async def main(port: int = 8222) -> None:
+ """
+ Example that starts the REPL through an SSH server.
+ """
+ # Namespace exposed in the REPL.
+ environ = {"hello": "world"}
+
+ # Start SSH server.
+ def create_server() -> MySSHServer:
+ return MySSHServer(lambda: environ)
+
+ print("Listening on :%i" % port)
+ print('To connect, do "ssh localhost -p %i"' % port)
+
+ await asyncssh.create_server(
+ create_server, "", port, server_host_keys=["/etc/ssh/ssh_host_dsa_key"]
+ )
+ await asyncio.Future() # Wait forever.
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/examples/ptpython_config/config.py b/examples/ptpython_config/config.py
new file mode 100644
index 0000000..b25850a
--- /dev/null
+++ b/examples/ptpython_config/config.py
@@ -0,0 +1,207 @@
+"""
+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
+from prompt_toolkit.keys import Keys
+from prompt_toolkit.styles import Style
+
+from ptpython.layout import CompletionVisualisation
+
+__all__ = ["configure"]
+
+
+def configure(repl):
+ """
+ Configuration method. This is called during the start-up of ptpython.
+
+ :param repl: `PythonRepl` instance.
+ """
+ # Show function signature (bool).
+ repl.show_signature = True
+
+ # Show docstring (bool).
+ repl.show_docstring = False
+
+ # Show the "[Meta+Enter] Execute" message when pressing [Enter] only
+ # inserts a newline instead of executing the code.
+ repl.show_meta_enter_message = True
+
+ # Show completions. (NONE, POP_UP, MULTI_COLUMN or TOOLBAR)
+ repl.completion_visualisation = CompletionVisualisation.POP_UP
+
+ # When CompletionVisualisation.POP_UP has been chosen, use this
+ # scroll_offset in the completion menu.
+ repl.completion_menu_scroll_offset = 0
+
+ # Show line numbers (when the input contains multiple lines.)
+ repl.show_line_numbers = False
+
+ # Show status bar.
+ repl.show_status_bar = True
+
+ # When the sidebar is visible, also show the help text.
+ repl.show_sidebar_help = True
+
+ # Swap light/dark colors on or off
+ repl.swap_light_and_dark = False
+
+ # Highlight matching parentheses.
+ repl.highlight_matching_parenthesis = True
+
+ # Line wrapping. (Instead of horizontal scrolling.)
+ repl.wrap_lines = True
+
+ # Mouse support.
+ repl.enable_mouse_support = True
+
+ # Complete while typing. (Don't require tab before the
+ # completion menu is shown.)
+ repl.complete_while_typing = True
+
+ # Fuzzy and dictionary completion.
+ repl.enable_fuzzy_completion = False
+ repl.enable_dictionary_completion = False
+
+ # 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
+
+ # Use the classic prompt. (Display '>>>' instead of 'In [1]'.)
+ repl.prompt_style = "classic" # 'classic' or 'ipython'
+
+ # Don't insert a blank line after the output.
+ repl.insert_blank_line_after_output = False
+
+ # History Search.
+ # When True, going back in history will filter the history on the records
+ # starting with the current input. (Like readline.)
+ # Note: When enable, please disable the `complete_while_typing` option.
+ # otherwise, when there is a completion available, the arrows will
+ # browse through the available completions instead of the history.
+ repl.enable_history_search = False
+
+ # Enable auto suggestions. (Pressing right arrow will complete the input,
+ # based on the history.)
+ repl.enable_auto_suggest = False
+
+ # Enable open-in-editor. Pressing C-x C-e in emacs mode or 'v' in
+ # Vi navigation mode will open the input in the current editor.
+ repl.enable_open_in_editor = True
+
+ # Enable system prompt. Pressing meta-! will display the system prompt.
+ # Also enables Control-Z suspend.
+ repl.enable_system_bindings = True
+
+ # Ask for confirmation on exit.
+ repl.confirm_exit = True
+
+ # Enable input validation. (Don't try to execute when the input contains
+ # syntax errors.)
+ 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")
+ # 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).
+
+ # repl.color_depth = "DEPTH_1_BIT" # Monochrome.
+ # repl.color_depth = "DEPTH_4_BIT" # ANSI colors only.
+ repl.color_depth = "DEPTH_8_BIT" # The default, 256 colors.
+ # repl.color_depth = "DEPTH_24_BIT" # True color.
+
+ # Min/max brightness
+ repl.min_brightness = 0.0 # Increase for dark terminal backgrounds.
+ repl.max_brightness = 1.0 # Decrease for light terminal backgrounds.
+
+ # Syntax.
+ repl.enable_syntax_highlighting = True
+
+ # Get into Vi navigation mode at startup
+ repl.vi_start_in_navigation_mode = False
+
+ # Preserve last used Vi input mode between main loop iterations
+ repl.vi_keep_last_used_mode = False
+
+ # Install custom colorscheme named 'my-colorscheme' and use it.
+ """
+ repl.install_ui_colorscheme("my-colorscheme", Style.from_dict(_custom_ui_colorscheme))
+ repl.use_ui_colorscheme("my-colorscheme")
+ """
+
+ # Add custom key binding for PDB.
+ """
+ @repl.add_key_binding("c-b")
+ def _(event):
+ " Pressing Control-B will insert "pdb.set_trace()" "
+ event.cli.current_buffer.insert_text("\nimport pdb; pdb.set_trace()\n")
+ """
+
+ # Typing ControlE twice should also execute the current command.
+ # (Alternative for Meta-Enter.)
+ """
+ @repl.add_key_binding("c-e", "c-e")
+ def _(event):
+ event.current_buffer.validate_and_handle()
+ """
+
+ # Typing 'jj' in Vi Insert mode, should send escape. (Go back to navigation
+ # mode.)
+ """
+ @repl.add_key_binding("j", "j", filter=ViInsertMode())
+ def _(event):
+ " Map 'jj' to Escape. "
+ event.cli.key_processor.feed(KeyPress(Keys("escape")))
+ """
+
+ # Custom key binding for some simple autocorrection while typing.
+ """
+ corrections = {
+ "impotr": "import",
+ "pritn": "print",
+ }
+
+ @repl.add_key_binding(" ")
+ def _(event):
+ " When a space is pressed. Check & correct word before cursor. "
+ b = event.cli.current_buffer
+ w = b.document.get_word_before_cursor()
+
+ if w is not None:
+ if w in corrections:
+ b.delete_before_cursor(count=len(w))
+ b.insert_text(corrections[w])
+
+ b.insert_text(" ")
+ """
+
+ # Add a custom title to the status bar. This is useful when ptpython is
+ # embedded in other applications.
+ """
+ repl.title = "My custom prompt."
+ """
+
+
+# Custom colorscheme for the UI. See `ptpython/layout.py` and
+# `ptpython/style.py` for all possible tokens.
+_custom_ui_colorscheme = {
+ # Blue prompt.
+ "prompt": "bg:#eeeeff #000000 bold",
+ # Make the status toolbar red.
+ "status-toolbar": "bg:#ff0000 #000000",
+}
diff --git a/examples/python-embed-with-custom-prompt.py b/examples/python-embed-with-custom-prompt.py
new file mode 100755
index 0000000..d54da1d
--- /dev/null
+++ b/examples/python-embed-with-custom-prompt.py
@@ -0,0 +1,38 @@
+#!/usr/bin/env python
+"""
+Example of embedding a Python REPL, and setting a custom prompt.
+"""
+from prompt_toolkit.formatted_text import HTML, AnyFormattedText
+
+from ptpython.prompt_style import PromptStyle
+from ptpython.repl import embed
+
+
+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) -> AnyFormattedText:
+ return HTML("<ansigreen>Input[%s]</ansigreen>: ") % (
+ repl.current_statement_index,
+ )
+
+ def in2_prompt(self, width: int) -> AnyFormattedText:
+ return "...: ".rjust(width)
+
+ def out_prompt(self) -> AnyFormattedText:
+ return HTML("<ansired>Result[%s]</ansired>: ") % (
+ repl.current_statement_index,
+ )
+
+ repl.all_prompt_styles["custom"] = CustomPrompt()
+ repl.prompt_style = "custom"
+
+
+def main() -> None:
+ embed(globals(), locals(), configure=configure)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/python-embed.py b/examples/python-embed.py
new file mode 100755
index 0000000..49224ac
--- /dev/null
+++ b/examples/python-embed.py
@@ -0,0 +1,12 @@
+#!/usr/bin/env python
+"""
+"""
+from ptpython.repl import embed
+
+
+def main() -> None:
+ embed(globals(), locals(), vi_mode=False)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/python-input.py b/examples/python-input.py
new file mode 100755
index 0000000..567c2ee
--- /dev/null
+++ b/examples/python-input.py
@@ -0,0 +1,15 @@
+#!/usr/bin/env python
+"""
+"""
+from ptpython.python_input import PythonInput
+
+
+def main():
+ prompt = PythonInput()
+
+ text = prompt.app.run()
+ print("You said: " + text)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/ssh-and-telnet-embed.py b/examples/ssh-and-telnet-embed.py
new file mode 100755
index 0000000..62fa76d
--- /dev/null
+++ b/examples/ssh-and-telnet-embed.py
@@ -0,0 +1,52 @@
+#!/usr/bin/env python
+"""
+Serve a ptpython console using both telnet and ssh.
+
+Thanks to Vincent Michel for this!
+https://gist.github.com/vxgmichel/7685685b3e5ead04ada4a3ba75a48eef
+"""
+
+import asyncio
+import pathlib
+
+import asyncssh
+from prompt_toolkit import print_formatted_text
+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: str = "ssh_host_key") -> str:
+ path = pathlib.Path(filename)
+ if not path.exists():
+ rsa_key = asyncssh.generate_private_key("ssh-rsa")
+ path.write_bytes(rsa_key.export_private_key())
+ return str(path)
+
+
+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: 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()]
+ )
+ print(f"Running ssh server on port {ssh_port}...")
+
+ telnet_server = TelnetServer(interact=interact, port=telnet_port)
+ telnet_server.start()
+ print(f"Running telnet server on port {telnet_port}...")
+
+ while True:
+ await asyncio.sleep(60)
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/examples/test-cases/ptpython-in-other-thread.py b/examples/test-cases/ptpython-in-other-thread.py
new file mode 100644
index 0000000..7c78846
--- /dev/null
+++ b/examples/test-cases/ptpython-in-other-thread.py
@@ -0,0 +1,24 @@
+#!/usr/bin/env python
+"""
+Example of running ptpython in another thread.
+
+(For testing whether it's working fine if it's not embedded in the main
+thread.)
+"""
+import threading
+
+from ptpython.repl import embed
+
+
+def in_thread():
+ embed(globals(), locals(), vi_mode=False)
+
+
+def main():
+ th = threading.Thread(target=in_thread)
+ th.start()
+ th.join()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/mypy.ini b/mypy.ini
new file mode 100644
index 0000000..5a7ef2e
--- /dev/null
+++ b/mypy.ini
@@ -0,0 +1,6 @@
+[mypy]
+ignore_missing_imports = True
+no_implicit_optional = True
+platform = win32
+strict_equality = True
+strict_optional = True
diff --git a/ptpython/__init__.py b/ptpython/__init__.py
new file mode 100644
index 0000000..63c6233
--- /dev/null
+++ b/ptpython/__init__.py
@@ -0,0 +1,5 @@
+from __future__ import annotations
+
+from .repl import embed
+
+__all__ = ["embed"]
diff --git a/ptpython/__main__.py b/ptpython/__main__.py
new file mode 100644
index 0000000..c006261
--- /dev/null
+++ b/ptpython/__main__.py
@@ -0,0 +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
new file mode 100644
index 0000000..91d6647
--- /dev/null
+++ b/ptpython/completer.py
@@ -0,0 +1,671 @@
+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, Iterable
+
+from prompt_toolkit.completion import (
+ CompleteEvent,
+ Completer,
+ Completion,
+ PathCompleter,
+)
+from prompt_toolkit.contrib.completers.system import SystemCompleter
+from prompt_toolkit.contrib.regular_languages.compiler import compile as compile_grammar
+from prompt_toolkit.contrib.regular_languages.completion import GrammarCompleter
+from prompt_toolkit.document import Document
+from prompt_toolkit.formatted_text import fragment_list_to_text, to_formatted_text
+
+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"]
+
+
+class CompletePrivateAttributes(Enum):
+ """
+ Should we display private attributes in the completion pop-up?
+ """
+
+ NEVER = "NEVER"
+ IF_NO_PUBLIC = "IF_NO_PUBLIC"
+ ALWAYS = "ALWAYS"
+
+
+class PythonCompleter(Completer):
+ """
+ Completer for Python code.
+ """
+
+ def __init__(
+ self,
+ get_globals: Callable[[], dict[str, Any]],
+ get_locals: Callable[[], dict[str, Any]],
+ enable_dictionary_completion: Callable[[], bool],
+ ) -> None:
+ super().__init__()
+
+ self.get_globals = get_globals
+ self.get_locals = get_locals
+ self.enable_dictionary_completion = enable_dictionary_completion
+
+ self._system_completer = SystemCompleter()
+ self._jedi_completer = JediCompleter(get_globals, get_locals)
+ self._dictionary_completer = DictionaryCompleter(get_globals, get_locals)
+
+ self._path_completer_cache: GrammarCompleter | None = None
+ self._path_completer_grammar_cache: _CompiledGrammar | None = None
+
+ @property
+ def _path_completer(self) -> GrammarCompleter:
+ if self._path_completer_cache is None:
+ self._path_completer_cache = GrammarCompleter(
+ self._path_completer_grammar,
+ {
+ "var1": PathCompleter(expanduser=True),
+ "var2": PathCompleter(expanduser=True),
+ },
+ )
+ return self._path_completer_cache
+
+ @property
+ def _path_completer_grammar(self) -> _CompiledGrammar:
+ """
+ Return the grammar for matching paths inside strings inside Python
+ code.
+ """
+ # We make this lazy, because it delays startup time a little bit.
+ # This way, the grammar is build during the first completion.
+ if self._path_completer_grammar_cache is None:
+ self._path_completer_grammar_cache = self._create_path_completer_grammar()
+ return self._path_completer_grammar_cache
+
+ def _create_path_completer_grammar(self) -> _CompiledGrammar:
+ def unwrapper(text: str) -> str:
+ return re.sub(r"\\(.)", r"\1", text)
+
+ def single_quoted_wrapper(text: str) -> str:
+ return text.replace("\\", "\\\\").replace("'", "\\'")
+
+ def double_quoted_wrapper(text: str) -> str:
+ return text.replace("\\", "\\\\").replace('"', '\\"')
+
+ grammar = r"""
+ # Text before the current string.
+ (
+ [^'"#] | # Not quoted characters.
+ ''' ([^'\\]|'(?!')|''(?!')|\\.])* ''' | # Inside single quoted triple strings
+ "" " ([^"\\]|"(?!")|""(?!^)|\\.])* "" " | # Inside double quoted triple strings
+
+ \#[^\n]*(\n|$) | # Comment.
+ "(?!"") ([^"\\]|\\.)*" | # Inside double quoted strings.
+ '(?!'') ([^'\\]|\\.)*' # Inside single quoted strings.
+
+ # Warning: The negative lookahead in the above two
+ # statements is important. If we drop that,
+ # then the regex will try to interpret every
+ # triple quoted string also as a single quoted
+ # string, making this exponentially expensive to
+ # execute!
+ )*
+ # The current string that we're completing.
+ (
+ ' (?P<var1>([^\n'\\]|\\.)*) | # Inside a single quoted string.
+ " (?P<var2>([^\n"\\]|\\.)*) # Inside a double quoted string.
+ )
+ """
+
+ return compile_grammar(
+ grammar,
+ escape_funcs={"var1": single_quoted_wrapper, "var2": double_quoted_wrapper},
+ unescape_funcs={"var1": unwrapper, "var2": unwrapper},
+ )
+
+ def _complete_path_while_typing(self, document: Document) -> bool:
+ char_before_cursor = document.char_before_cursor
+ return bool(
+ document.text
+ and (char_before_cursor.isalnum() or char_before_cursor in "/.~")
+ )
+
+ def _complete_python_while_typing(self, document: Document) -> bool:
+ """
+ When `complete_while_typing` is set, only return completions when this
+ returns `True`.
+ """
+ text = document.text_before_cursor # .rstrip()
+ char_before_cursor = text[-1:]
+ return bool(
+ text and (char_before_cursor.isalnum() or char_before_cursor in "_.([,")
+ )
+
+ def get_completions(
+ self, document: Document, complete_event: CompleteEvent
+ ) -> Iterable[Completion]:
+ """
+ Get Python completions.
+ """
+ # If the input starts with an exclamation mark. Use the system completer.
+ if document.text.lstrip().startswith("!"):
+ yield from self._system_completer.get_completions(
+ Document(
+ text=document.text[1:], cursor_position=document.cursor_position - 1
+ ),
+ complete_event,
+ )
+ return
+
+ # Do dictionary key completions.
+ if complete_event.completion_requested or self._complete_python_while_typing(
+ document
+ ):
+ if self.enable_dictionary_completion():
+ has_dict_completions = False
+ for c in self._dictionary_completer.get_completions(
+ document, complete_event
+ ):
+ if c.text not in "[.":
+ # If we get the [ or . completion, still include the other
+ # completions.
+ has_dict_completions = True
+ yield c
+ if has_dict_completions:
+ return
+
+ # Do Path completions (if there were no dictionary completions).
+ if complete_event.completion_requested or self._complete_path_while_typing(
+ document
+ ):
+ yield from self._path_completer.get_completions(document, complete_event)
+
+ # Do Jedi completions.
+ if complete_event.completion_requested or self._complete_python_while_typing(
+ document
+ ):
+ # 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
+ )
+
+
+class JediCompleter(Completer):
+ """
+ Autocompleter that uses the Jedi library.
+ """
+
+ def __init__(
+ self,
+ get_globals: Callable[[], dict[str, Any]],
+ get_locals: Callable[[], dict[str, Any]],
+ ) -> None:
+ super().__init__()
+
+ self.get_globals = get_globals
+ self.get_locals = get_locals
+
+ def get_completions(
+ self, document: Document, complete_event: CompleteEvent
+ ) -> Iterable[Completion]:
+ script = get_jedi_script_from_document(
+ document, self.get_locals(), self.get_globals()
+ )
+
+ if script:
+ try:
+ jedi_completions = script.complete(
+ column=document.cursor_position_col,
+ line=document.cursor_position_row + 1,
+ )
+ except TypeError:
+ # Issue #9: bad syntax causes completions() to fail in jedi.
+ # https://github.com/jonathanslenders/python-prompt-toolkit/issues/9
+ pass
+ except UnicodeDecodeError:
+ # Issue #43: UnicodeDecodeError on OpenBSD
+ # https://github.com/jonathanslenders/python-prompt-toolkit/issues/43
+ pass
+ except AttributeError:
+ # Jedi issue #513: https://github.com/davidhalter/jedi/issues/513
+ pass
+ except ValueError:
+ # Jedi issue: "ValueError: invalid \x escape"
+ pass
+ except KeyError:
+ # Jedi issue: "KeyError: u'a_lambda'."
+ # https://github.com/jonathanslenders/ptpython/issues/89
+ pass
+ except OSError:
+ # Jedi issue: "IOError: No such file or directory."
+ # https://github.com/jonathanslenders/ptpython/issues/71
+ pass
+ except AssertionError:
+ # In jedi.parser.__init__.py: 227, in remove_last_newline,
+ # the assertion "newline.value.endswith('\n')" can fail.
+ pass
+ except SystemError:
+ # In jedi.api.helpers.py: 144, in get_stack_at_position
+ # raise SystemError("This really shouldn't happen. There's a bug in Jedi.")
+ pass
+ except NotImplementedError:
+ # See: https://github.com/jonathanslenders/ptpython/issues/223
+ pass
+ except Exception:
+ # Suppress all other Jedi exceptions.
+ pass
+ else:
+ # Move function parameters to the top.
+ jedi_completions = sorted(
+ jedi_completions,
+ key=lambda jc: (
+ # Params first.
+ jc.type != "param",
+ # Private at the end.
+ jc.name.startswith("_"),
+ # Then sort by name.
+ jc.name_with_symbols.lower(),
+ ),
+ )
+
+ for jc in jedi_completions:
+ if jc.type == "function":
+ suffix = "()"
+ else:
+ suffix = ""
+
+ if jc.type == "param":
+ suffix = "..."
+
+ yield Completion(
+ jc.name_with_symbols,
+ len(jc.complete) - len(jc.name_with_symbols),
+ display=jc.name_with_symbols + suffix,
+ display_meta=jc.type,
+ style=_get_style_for_jedi_completion(jc),
+ )
+
+
+class DictionaryCompleter(Completer):
+ """
+ Experimental completer for Python dictionary keys.
+
+ Warning: This does an `eval` and `repr` on some Python expressions before
+ the cursor, which is potentially dangerous. It doesn't match on
+ function calls, so it only triggers attribute access.
+ """
+
+ def __init__(
+ self,
+ get_globals: Callable[[], dict[str, Any]],
+ get_locals: Callable[[], dict[str, Any]],
+ ) -> None:
+ super().__init__()
+
+ self.get_globals = get_globals
+ self.get_locals = get_locals
+
+ # Pattern for expressions that are "safe" to eval for auto-completion.
+ # These are expressions that contain only attribute and index lookups.
+ varname = r"[a-zA-Z_][a-zA-Z0-9_]*"
+
+ expression = rf"""
+ # Any expression safe enough to eval while typing.
+ # No operators, except dot, and only other dict lookups.
+ # Technically, this can be unsafe of course, if bad code runs
+ # in `__getattr__` or ``__getitem__``.
+ (
+ # Variable name
+ {varname}
+
+ \s*
+
+ (?:
+ # Attribute access.
+ \s* \. \s* {varname} \s*
+
+ |
+
+ # Item lookup.
+ # (We match the square brackets. The key can be anything.
+ # We don't care about matching quotes here in the regex.
+ # Nested square brackets are not supported.)
+ \s* \[ [^\[\]]+ \] \s*
+ )*
+ )
+ """
+
+ # Pattern for recognizing for-loops, so that we can provide
+ # autocompletion on the iterator of the for-loop. (According to the
+ # first item of the collection we're iterating over.)
+ self.for_loop_pattern = re.compile(
+ rf"""
+ for \s+ ([a-zA-Z0-9_]+) \s+ in \s+ {expression} \s* :
+ """,
+ re.VERBOSE,
+ )
+
+ # Pattern for matching a simple expression (for completing [ or .
+ # operators).
+ self.expression_pattern = re.compile(
+ rf"""
+ {expression}
+ $
+ """,
+ re.VERBOSE,
+ )
+
+ # Pattern for matching item lookups.
+ self.item_lookup_pattern = re.compile(
+ rf"""
+ {expression}
+
+ # Dict lookup to complete (square bracket open + start of
+ # string).
+ \[
+ \s* ([^\[\]]*)$
+ """,
+ re.VERBOSE,
+ )
+
+ # Pattern for matching attribute lookups.
+ self.attribute_lookup_pattern = re.compile(
+ rf"""
+ {expression}
+
+ # Attribute lookup to complete (dot + varname).
+ \.
+ \s* ([a-zA-Z0-9_]*)$
+ """,
+ re.VERBOSE,
+ )
+
+ 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.
+ """
+ try:
+ return eval(expression.strip(), self.get_globals(), temp_locals)
+ except BaseException:
+ return None # Many exception, like NameError can be thrown here.
+
+ 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.
+ temp_locals = self.get_locals().copy()
+
+ for match in self.for_loop_pattern.finditer(document.text_before_cursor):
+ varname, expression = match.groups()
+ expression_val = self._lookup(expression, temp_locals)
+
+ # We do this only for lists and tuples. Calling `next()` on any
+ # collection would create undesired side effects.
+ if isinstance(expression_val, (list, tuple)) and expression_val:
+ temp_locals[varname] = expression_val[0]
+
+ # Get all completions.
+ yield from self._get_expression_completions(
+ document, complete_event, temp_locals
+ )
+ yield from self._get_item_lookup_completions(
+ document, complete_event, temp_locals
+ )
+ yield from self._get_attribute_completions(
+ document, complete_event, temp_locals
+ )
+
+ def _do_repr(self, obj: object) -> str:
+ try:
+ return str(repr(obj))
+ except BaseException:
+ raise ReprFailedError
+
+ def eval_expression(self, document: Document, locals: dict[str, Any]) -> object:
+ """
+ Evaluate
+ """
+ match = self.expression_pattern.search(document.text_before_cursor)
+ if match is not None:
+ object_var = match.groups()[0]
+ return self._lookup(object_var, locals)
+
+ return None
+
+ def _get_expression_completions(
+ self,
+ document: Document,
+ complete_event: CompleteEvent,
+ temp_locals: dict[str, Any],
+ ) -> Iterable[Completion]:
+ """
+ Complete the [ or . operator after an object.
+ """
+ result = self.eval_expression(document, temp_locals)
+
+ if result is not None:
+ if isinstance(
+ result,
+ (list, tuple, dict, collections_abc.Mapping, collections_abc.Sequence),
+ ):
+ yield Completion("[", 0)
+
+ else:
+ # Note: Don't call `if result` here. That can fail for types
+ # that have custom truthness checks.
+ yield Completion(".", 0)
+
+ def _get_item_lookup_completions(
+ self,
+ document: Document,
+ complete_event: CompleteEvent,
+ temp_locals: dict[str, Any],
+ ) -> Iterable[Completion]:
+ """
+ Complete dictionary keys.
+ """
+
+ 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:
+ object_var, key = match.groups()
+
+ # Do lookup of `object_var` in the context.
+ result = self._lookup(object_var, temp_locals)
+
+ # If this object is a dictionary, complete the keys.
+ if isinstance(result, (dict, collections_abc.Mapping)):
+ # Try to evaluate the key.
+ key_obj = key
+ for k in [key, key + '"', key + "'"]:
+ try:
+ key_obj = ast.literal_eval(k)
+ except (SyntaxError, ValueError):
+ continue
+ else:
+ break
+
+ for k, v in result.items():
+ if str(k).startswith(str(key_obj)):
+ try:
+ k_repr = self._do_repr(k)
+ yield Completion(
+ k_repr + "]",
+ -len(key),
+ display=f"[{k_repr}]",
+ display_meta=meta_repr(v),
+ )
+ except ReprFailedError:
+ pass
+
+ # Complete list/tuple index keys.
+ elif isinstance(result, (list, tuple, collections_abc.Sequence)):
+ if not key or key.isdigit():
+ for k in range(min(len(result), 1000)):
+ if str(k).startswith(key):
+ try:
+ k_repr = self._do_repr(k)
+ yield Completion(
+ k_repr + "]",
+ -len(key),
+ display=f"[{k_repr}]",
+ display_meta=meta_repr(result[k]),
+ )
+ except KeyError:
+ # `result[k]` lookup failed. Trying to complete
+ # broken object.
+ pass
+ except ReprFailedError:
+ pass
+
+ def _get_attribute_completions(
+ self,
+ document: Document,
+ complete_event: CompleteEvent,
+ temp_locals: dict[str, Any],
+ ) -> Iterable[Completion]:
+ """
+ Complete attribute names.
+ """
+ match = self.attribute_lookup_pattern.search(document.text_before_cursor)
+ if match is not None:
+ object_var, attr_name = match.groups()
+
+ # Do lookup of `object_var` in the context.
+ result = self._lookup(object_var, temp_locals)
+
+ names = self._sort_attribute_names(dir(result))
+
+ def get_suffix(name: str) -> str:
+ try:
+ obj = getattr(result, name, None)
+ if inspect.isfunction(obj) or inspect.ismethod(obj):
+ return "()"
+ if isinstance(obj, collections_abc.Mapping):
+ return "{}"
+ if isinstance(obj, collections_abc.Sequence):
+ return "[]"
+ except:
+ pass
+ return ""
+
+ for name in names:
+ if name.startswith(attr_name):
+ suffix = get_suffix(name)
+ yield Completion(name, -len(attr_name), display=name + suffix)
+
+ 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) -> tuple[int, str]:
+ if name.startswith("__"):
+ return (2, name) # Double underscore comes latest.
+ if name.startswith("_"):
+ return (1, name) # Single underscore before that.
+ return (0, name) # Other names first.
+
+ return sorted(names, key=sort_key)
+
+
+class HidePrivateCompleter(Completer):
+ """
+ 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
+ way it works also with `FuzzyCompleter`.)
+ """
+
+ def __init__(
+ self,
+ completer: Completer,
+ complete_private_attributes: Callable[[], CompletePrivateAttributes],
+ ) -> None:
+ self.completer = completer
+ self.complete_private_attributes = complete_private_attributes
+
+ 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
+
+ def is_private(completion: Completion) -> bool:
+ text = fragment_list_to_text(to_formatted_text(completion.display))
+ return text.startswith("_")
+
+ if complete_private_attributes == CompletePrivateAttributes.NEVER:
+ hide_private = True
+
+ elif complete_private_attributes == CompletePrivateAttributes.IF_NO_PUBLIC:
+ hide_private = any(not is_private(completion) for completion in completions)
+
+ if hide_private:
+ completions = [
+ completion for completion in completions if not is_private(completion)
+ ]
+
+ return completions
+
+
+class ReprFailedError(Exception):
+ "Raised when the repr() call in `DictionaryCompleter` fails."
+
+
+try:
+ import builtins
+
+ _builtin_names = dir(builtins)
+except ImportError: # Python 2.
+ _builtin_names = []
+
+
+def _get_style_for_jedi_completion(
+ jedi_completion: jedi.api.classes.Completion,
+) -> str:
+ """
+ Return completion style to use for this name.
+ """
+ name = jedi_completion.name_with_symbols
+
+ if jedi_completion.type == "param":
+ return "class:completion.param"
+
+ if name in _builtin_names:
+ return "class:completion.builtin"
+
+ if keyword.iskeyword(name):
+ return "class:completion.keyword"
+
+ return ""
diff --git a/ptpython/contrib/__init__.py b/ptpython/contrib/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/ptpython/contrib/__init__.py
diff --git a/ptpython/contrib/asyncssh_repl.py b/ptpython/contrib/asyncssh_repl.py
new file mode 100644
index 0000000..2f74eb2
--- /dev/null
+++ b/ptpython/contrib/asyncssh_repl.py
@@ -0,0 +1,125 @@
+"""
+Tool for embedding a REPL inside a Python 3 asyncio process.
+See ./examples/asyncio-ssh-python-embed.py for a demo.
+
+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, 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, _Namespace
+from ptpython.repl import PythonRepl
+
+__all__ = ["ReplSSHServerSession"]
+
+
+class ReplSSHServerSession(asyncssh.SSHServerSession[str]):
+ """
+ SSH server session that runs a Python REPL.
+
+ :param get_globals: callable that returns the current globals.
+ :param get_locals: (optional) callable that returns the current locals.
+ """
+
+ def __init__(
+ self, get_globals: _GetNamespace, get_locals: _GetNamespace | None = None
+ ) -> None:
+ self._chan: Any = None
+
+ def _globals() -> _Namespace:
+ data = get_globals()
+ data.setdefault("print", self._print)
+ return data
+
+ # PipInput object, for sending input in the CLI.
+ # (This is something that we can use in the prompt_toolkit event loop,
+ # but still write date in manually.)
+ self._input_pipe = create_pipe_input()
+
+ # Output object. Don't render to the real stdout, but write everything
+ # in the SSH channel.
+ class Stdout:
+ def write(s, data: str) -> None:
+ if self._chan is not None:
+ data = data.replace("\n", "\r\n")
+ self._chan.write(data)
+
+ def flush(s) -> None:
+ pass
+
+ self.repl = PythonRepl(
+ get_globals=_globals,
+ get_locals=get_locals or _globals,
+ input=self._input_pipe,
+ output=Vt100_Output(cast(TextIO, Stdout()), self._get_size),
+ )
+
+ # Disable open-in-editor and system prompt. Because it would run and
+ # display these commands on the server side, rather than in the SSH
+ # client.
+ self.repl.enable_open_in_editor = False
+ self.repl.enable_system_bindings = False
+
+ def _get_size(self) -> Size:
+ """
+ Callable that returns the current `Size`, required by Vt100_Output.
+ """
+ if self._chan is None:
+ return Size(rows=20, columns=79)
+ else:
+ width, height, pixwidth, pixheight = self._chan.get_terminal_size()
+ return Size(rows=height, columns=width)
+
+ def connection_made(self, chan: Any) -> None:
+ """
+ Client connected, run repl in coroutine.
+ """
+ self._chan = chan
+
+ # Run REPL interface.
+ f = asyncio.ensure_future(self.repl.run_async())
+
+ # Close channel when done.
+ def done(_: object) -> None:
+ chan.close()
+ self._chan = None
+
+ f.add_done_callback(done)
+
+ def shell_requested(self) -> bool:
+ return True
+
+ 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: AnyStr, datatype: int | None) -> None:
+ """
+ When data is received, send to inputstream of the CLI and repaint.
+ """
+ self._input_pipe.send(data) # type: ignore
+
+ 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_as_str = sep.join(map(str, data))
+ self._chan.write(data_as_str + end)
diff --git a/ptpython/entry_points/__init__.py b/ptpython/entry_points/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/ptpython/entry_points/__init__.py
diff --git a/ptpython/entry_points/run_ptipython.py b/ptpython/entry_points/run_ptipython.py
new file mode 100644
index 0000000..b660a0a
--- /dev/null
+++ b/ptpython/entry_points/run_ptipython.py
@@ -0,0 +1,82 @@
+#!/usr/bin/env python
+from __future__ import annotations
+
+import os
+import sys
+
+from .run_ptpython import create_parser, get_config_and_history_file
+
+
+def run(user_ns=None):
+ a = create_parser().parse_args()
+
+ config_file, history_file = get_config_and_history_file(a)
+
+ # If IPython is not available, show message and exit here with error status
+ # code.
+ try:
+ import IPython
+ except ImportError:
+ print("IPython not found. Please install IPython (pip install ipython).")
+ sys.exit(1)
+ else:
+ from ptpython.ipython import embed
+ from ptpython.repl import enable_deprecation_warnings, run_config
+
+ # Add the current directory to `sys.path`.
+ if sys.path[0] != "":
+ sys.path.insert(0, "")
+
+ # When a file has been given, run that, otherwise start the shell.
+ if a.args and not a.interactive:
+ sys.argv = a.args
+ path = a.args[0]
+ with open(path, "rb") as f:
+ code = compile(f.read(), path, "exec")
+ exec(code, {"__name__": "__main__", "__file__": path})
+ else:
+ enable_deprecation_warnings()
+
+ # Create an empty namespace for this interactive shell. (If we don't do
+ # that, all the variables from this function will become available in
+ # the IPython shell.)
+ if user_ns is None:
+ user_ns = {}
+
+ # Startup path
+ startup_paths = []
+ if "PYTHONSTARTUP" in os.environ:
+ startup_paths.append(os.environ["PYTHONSTARTUP"])
+
+ # --interactive
+ if a.interactive:
+ startup_paths.append(a.args[0])
+ sys.argv = a.args
+
+ # exec scripts from startup paths
+ for path in startup_paths:
+ if os.path.exists(path):
+ with open(path, "rb") as f:
+ code = compile(f.read(), path, "exec")
+ exec(code, user_ns, user_ns)
+ else:
+ print(f"File not found: {path}\n\n")
+ sys.exit(1)
+
+ # Apply config file
+ def configure(repl):
+ if os.path.exists(config_file):
+ run_config(repl, config_file)
+
+ # Run interactive shell.
+ embed(
+ vi_mode=a.vi,
+ history_filename=history_file,
+ configure=configure,
+ user_ns=user_ns,
+ title="IPython REPL (ptipython)",
+ )
+
+
+if __name__ == "__main__":
+ run()
diff --git a/ptpython/entry_points/run_ptpython.py b/ptpython/entry_points/run_ptpython.py
new file mode 100644
index 0000000..1d4a532
--- /dev/null
+++ b/ptpython/entry_points/run_ptpython.py
@@ -0,0 +1,234 @@
+#!/usr/bin/env python
+"""
+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)
+"""
+from __future__ import annotations
+
+import argparse
+import asyncio
+import os
+import pathlib
+import sys
+from textwrap import dedent
+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 PythonRepl, embed, enable_deprecation_warnings, run_config
+
+try:
+ from importlib import metadata # type: ignore
+except ImportError:
+ import importlib_metadata as metadata # type: ignore
+
+
+__all__ = ["create_parser", "get_config_and_history_file", "run"]
+
+
+class _Parser(argparse.ArgumentParser):
+ def print_help(self, file: IO[str] | None = None) -> None:
+ super().print_help()
+ print(
+ dedent(
+ """
+ environment variables:
+ PTPYTHON_CONFIG_HOME: a configuration directory to use
+ PYTHONSTARTUP: file executed on interactive startup (no default)
+ """,
+ ).rstrip(),
+ )
+
+
+def create_parser() -> _Parser:
+ parser = _Parser(description="ptpython: Interactive Python shell.")
+ parser.add_argument("--vi", action="store_true", help="Enable Vi key bindings")
+ parser.add_argument(
+ "-i",
+ "--interactive",
+ action="store_true",
+ help="Start interactive shell after executing this file.",
+ )
+ parser.add_argument(
+ "--asyncio",
+ action="store_true",
+ help='Run an asyncio event loop to support top-level "await".',
+ )
+ parser.add_argument(
+ "--light-bg",
+ action="store_true",
+ help="Run on a light background (use dark colors for text).",
+ )
+ parser.add_argument(
+ "--dark-bg",
+ action="store_true",
+ help="Run on a dark background (use light colors for text).",
+ )
+ parser.add_argument(
+ "--config-file", type=str, help="Location of configuration file."
+ )
+ parser.add_argument("--history-file", type=str, help="Location of history file.")
+ parser.add_argument(
+ "-V",
+ "--version",
+ action="version",
+ 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]:
+ """
+ Check which config/history files to use, ensure that the directories for
+ these files exist, and return the config and history path.
+ """
+ config_dir = os.environ.get(
+ "PTPYTHON_CONFIG_HOME",
+ appdirs.user_config_dir("ptpython", "prompt_toolkit"),
+ )
+ data_dir = appdirs.user_data_dir("ptpython", "prompt_toolkit")
+
+ # Create directories.
+ for d in (config_dir, data_dir):
+ pathlib.Path(d).mkdir(parents=True, exist_ok=True)
+
+ # Determine config file to be used.
+ config_file = os.path.join(config_dir, "config.py")
+ legacy_config_file = os.path.join(os.path.expanduser("~/.ptpython"), "config.py")
+
+ warnings = []
+
+ # Config file
+ if namespace.config_file:
+ # Override config_file.
+ config_file = os.path.expanduser(namespace.config_file)
+
+ elif os.path.isfile(legacy_config_file):
+ # Warn about the legacy configuration file.
+ warnings.append(
+ HTML(
+ " <i>~/.ptpython/config.py</i> is deprecated, move your configuration to <i>%s</i>\n"
+ )
+ % config_file
+ )
+ config_file = legacy_config_file
+
+ # Determine history file to be used.
+ history_file = os.path.join(data_dir, "history")
+ legacy_history_file = os.path.join(os.path.expanduser("~/.ptpython"), "history")
+
+ if namespace.history_file:
+ # Override history_file.
+ history_file = os.path.expanduser(namespace.history_file)
+
+ elif os.path.isfile(legacy_history_file):
+ # Warn about the legacy history file.
+ warnings.append(
+ HTML(
+ " <i>~/.ptpython/history</i> is deprecated, move your history to <i>%s</i>\n"
+ )
+ % history_file
+ )
+ history_file = legacy_history_file
+
+ # Print warnings.
+ if warnings:
+ print_formatted_text(HTML("<u>Warning:</u>"))
+ for w in warnings:
+ print_formatted_text(w)
+
+ return config_file, history_file
+
+
+def run() -> None:
+ a = create_parser().parse_args()
+
+ config_file, history_file = get_config_and_history_file(a)
+
+ # Startup path
+ startup_paths = []
+ if "PYTHONSTARTUP" in os.environ:
+ startup_paths.append(os.environ["PYTHONSTARTUP"])
+
+ # --interactive
+ if a.interactive and a.args:
+ # Note that we shouldn't run PYTHONSTARTUP when -i is given.
+ startup_paths = [a.args[0]]
+ sys.argv = a.args
+
+ # Add the current directory to `sys.path`.
+ if sys.path[0] != "":
+ sys.path.insert(0, "")
+
+ # When a file has been given, run that, otherwise start the shell.
+ if a.args and not a.interactive:
+ sys.argv = a.args
+ path = a.args[0]
+ with open(path, "rb") as f:
+ code = compile(f.read(), path, "exec")
+ # 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: PythonRepl) -> None:
+ if os.path.exists(config_file):
+ run_config(repl, config_file)
+
+ # Adjust colors if dark/light background flag has been given.
+ if a.light_bg:
+ repl.min_brightness = 0.0
+ repl.max_brightness = 0.60
+ elif a.dark_bg:
+ repl.min_brightness = 0.60
+ repl.max_brightness = 1.0
+
+ import __main__
+
+ embed_result = embed( # type: ignore
+ vi_mode=a.vi,
+ history_filename=history_file,
+ configure=configure,
+ locals=__main__.__dict__,
+ 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
new file mode 100644
index 0000000..14ab64b
--- /dev/null
+++ b/ptpython/eventloop.py
@@ -0,0 +1,75 @@
+"""
+Wrapper around the eventloop that gives some time to the Tkinter GUI to process
+events when it's loaded and while we are waiting for input at the REPL. This
+way we don't block the UI of for instance ``turtle`` and other Tk libraries.
+
+(Normally Tkinter registers it's callbacks in ``PyOS_InputHook`` to integrate
+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: InputHookContext) -> None:
+ """
+ Inputhook for Tk.
+ Run the Tk eventloop until prompt-toolkit needs to process the next input.
+ """
+ # Get the current TK application.
+ import tkinter
+
+ import _tkinter # Keep this imports inline!
+
+ root = tkinter._default_root # type: ignore
+
+ def wait_using_filehandler() -> None:
+ """
+ Run the TK eventloop until the file handler that we got from the
+ inputhook becomes readable.
+ """
+ # Add a handler that sets the stop flag when `prompt-toolkit` has input
+ # to process.
+ stop = [False]
+
+ def done(*a: object) -> None:
+ stop[0] = True
+
+ root.createfilehandler(inputhook_context.fileno(), _tkinter.READABLE, done)
+
+ # Run the TK event loop as long as we don't receive input.
+ while root.dooneevent(_tkinter.ALL_EVENTS):
+ if stop[0]:
+ break
+
+ root.deletefilehandler(inputhook_context.fileno())
+
+ def wait_using_polling() -> None:
+ """
+ Windows TK doesn't support 'createfilehandler'.
+ So, run the TK eventloop and poll until input is ready.
+ """
+ while not inputhook_context.input_is_ready():
+ while root.dooneevent(_tkinter.ALL_EVENTS | _tkinter.DONT_WAIT):
+ pass
+ # Sleep to make the CPU idle, but not too long, so that the UI
+ # stays responsive.
+ time.sleep(0.01)
+
+ if root is not None:
+ if hasattr(root, "createfilehandler"):
+ wait_using_filehandler()
+ else:
+ wait_using_polling()
+
+
+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
new file mode 100644
index 0000000..a2079fd
--- /dev/null
+++ b/ptpython/filters.py
@@ -0,0 +1,39 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from prompt_toolkit.filters import Filter
+
+if TYPE_CHECKING:
+ from .python_input import PythonInput
+
+__all__ = ["HasSignature", "ShowSidebar", "ShowSignature", "ShowDocstring"]
+
+
+class PythonInputFilter(Filter):
+ def __init__(self, python_input: PythonInput) -> None:
+ super().__init__()
+ self.python_input = python_input
+
+ def __call__(self) -> bool:
+ raise NotImplementedError
+
+
+class HasSignature(PythonInputFilter):
+ def __call__(self) -> bool:
+ return bool(self.python_input.signatures)
+
+
+class ShowSidebar(PythonInputFilter):
+ def __call__(self) -> bool:
+ return self.python_input.show_sidebar
+
+
+class ShowSignature(PythonInputFilter):
+ def __call__(self) -> bool:
+ return self.python_input.show_signature
+
+
+class ShowDocstring(PythonInputFilter):
+ def __call__(self) -> bool:
+ return self.python_input.show_docstring
diff --git a/ptpython/history_browser.py b/ptpython/history_browser.py
new file mode 100644
index 0000000..b667be1
--- /dev/null
+++ b/ptpython/history_browser.py
@@ -0,0 +1,687 @@
+"""
+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
+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,
+ Float,
+ FloatContainer,
+ HSplit,
+ ScrollOffsets,
+ VSplit,
+ Window,
+ WindowAlign,
+ WindowRenderInfo,
+)
+from prompt_toolkit.layout.controls import (
+ BufferControl,
+ FormattedTextControl,
+ UIContent,
+)
+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,
+ 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
+from pygments.lexers import RstLexer
+
+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.
+
+Typical usage
+-------------
+
+1. Move the ``cursor up`` in the history pane, until the
+ cursor is on the first desired line.
+2. Hold down the ``space bar``, or press it multiple
+ times. Each time it will select one line and move to
+ the next one. Each selected line will appear on the
+ right side.
+3. When all the required lines are displayed on the right
+ side, press ``Enter``. This will go back to the Python
+ REPL and show these lines as the current input. They
+ can still be edited from there.
+
+Key bindings
+------------
+
+Many Emacs and Vi navigation key bindings should work.
+Press ``F4`` to switch between Emacs and Vi mode.
+
+Additional bindings:
+
+- ``Space``: Select or delect a line.
+- ``Tab``: Move the focus between the history and input
+ pane. (Alternative: ``Ctrl-W``)
+- ``Ctrl-C``: Cancel. Ignore the result and go back to
+ the REPL. (Alternatives: ``q`` and ``Control-G``.)
+- ``Enter``: Accept the result and go back to the REPL.
+- ``F1``: Show/hide help. Press ``Enter`` to quit this
+ help message.
+
+Further, remember that searching works like in Emacs
+(using ``Ctrl-R``) or Vi (using ``/``).
+"""
+
+
+class BORDER:
+ "Box drawing characters."
+
+ HORIZONTAL = "\u2501"
+ VERTICAL = "\u2503"
+ TOP_LEFT = "\u250f"
+ TOP_RIGHT = "\u2513"
+ BOTTOM_LEFT = "\u2517"
+ BOTTOM_RIGHT = "\u251b"
+ LIGHT_VERTICAL = "\u2502"
+
+
+def _create_popup_window(title: str, body: Container) -> Frame:
+ """
+ Return the layout for a pop-up window. It consists of a title bar showing
+ the `title` text, and a body layout. The window is surrounded by borders.
+ """
+ return Frame(body=body, title=title)
+
+
+class HistoryLayout:
+ """
+ Create and return a `Container` instance for the history
+ application.
+ """
+
+ def __init__(self, history: PythonHistory) -> None:
+ search_toolbar = SearchToolbar()
+
+ self.help_buffer_control = BufferControl(
+ buffer=history.help_buffer, lexer=PygmentsLexer(RstLexer)
+ )
+
+ help_window = _create_popup_window(
+ title="History Help",
+ body=Window(
+ content=self.help_buffer_control,
+ right_margins=[ScrollbarMargin(display_arrows=True)],
+ scroll_offsets=ScrollOffsets(top=2, bottom=2),
+ ),
+ )
+
+ self.default_buffer_control = BufferControl(
+ buffer=history.default_buffer,
+ input_processors=[GrayExistingText(history.history_mapping)],
+ lexer=PygmentsLexer(PythonLexer),
+ )
+
+ self.history_buffer_control = BufferControl(
+ buffer=history.history_buffer,
+ lexer=PygmentsLexer(PythonLexer),
+ search_buffer_control=search_toolbar.control,
+ preview_search=True,
+ )
+
+ history_window = Window(
+ content=self.history_buffer_control,
+ wrap_lines=False,
+ left_margins=[HistoryMargin(history)],
+ scroll_offsets=ScrollOffsets(top=2, bottom=2),
+ )
+
+ self.root_container = HSplit(
+ [
+ # Top title bar.
+ Window(
+ content=FormattedTextControl(_get_top_toolbar_fragments),
+ align=WindowAlign.CENTER,
+ style="class:status-toolbar",
+ ),
+ FloatContainer(
+ content=VSplit(
+ [
+ # Left side: history.
+ history_window,
+ # Separator.
+ Window(
+ width=D.exact(1),
+ char=BORDER.LIGHT_VERTICAL,
+ style="class:separator",
+ ),
+ # Right side: result.
+ Window(
+ content=self.default_buffer_control,
+ wrap_lines=False,
+ left_margins=[ResultMargin(history)],
+ scroll_offsets=ScrollOffsets(top=2, bottom=2),
+ ),
+ ]
+ ),
+ floats=[
+ # Help text as a float.
+ Float(
+ width=60,
+ top=3,
+ bottom=2,
+ content=ConditionalContainer(
+ content=help_window,
+ filter=has_focus(history.help_buffer),
+ ),
+ )
+ ],
+ ),
+ # Bottom toolbars.
+ ArgToolbar(),
+ search_toolbar,
+ Window(
+ content=FormattedTextControl(
+ partial(_get_bottom_toolbar_fragments, history=history)
+ ),
+ style="class:status-toolbar",
+ ),
+ ]
+ )
+
+ self.layout = Layout(self.root_container, history_window)
+
+
+def _get_top_toolbar_fragments() -> StyleAndTextTuples:
+ return [("class:status-bar.title", "History browser - Insert from history")]
+
+
+def _get_bottom_toolbar_fragments(history: PythonHistory) -> StyleAndTextTuples:
+ python_input = history.python_input
+
+ @if_mousedown
+ def f1(mouse_event: MouseEvent) -> None:
+ _toggle_help(history)
+
+ @if_mousedown
+ def tab(mouse_event: MouseEvent) -> None:
+ _select_other_window(history)
+
+ return (
+ [("class:status-toolbar", " ")]
+ + get_inputmode_fragments(python_input)
+ + [
+ ("class:status-toolbar", " "),
+ ("class:status-toolbar.key", "[Space]"),
+ ("class:status-toolbar", " Toggle "),
+ ("class:status-toolbar.key", "[Tab]", tab),
+ ("class:status-toolbar", " Focus ", tab),
+ ("class:status-toolbar.key", "[Enter]"),
+ ("class:status-toolbar", " Accept "),
+ ("class:status-toolbar.key", "[F1]", f1),
+ ("class:status-toolbar", " Help ", f1),
+ ]
+ )
+
+
+class HistoryMargin(Margin):
+ """
+ Margin for the history buffer.
+ This displays a green bar for the selected entries.
+ """
+
+ def __init__(self, history: PythonHistory) -> None:
+ self.history_buffer = history.history_buffer
+ self.history_mapping = history.history_mapping
+
+ def get_width(self, get_ui_content: Callable[[], UIContent]) -> int:
+ return 2
+
+ 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
+ selected_lines = self.history_mapping.selected_lines
+
+ current_lineno = document.cursor_position_row
+
+ visible_line_to_input_line = window_render_info.visible_line_to_input_line
+ result: StyleAndTextTuples = []
+
+ for y in range(height):
+ line_number = visible_line_to_input_line.get(y)
+
+ # Show stars at the start of each entry.
+ # (Visualises multiline entries.)
+ if line_number in lines_starting_new_entries:
+ char = "*"
+ else:
+ char = " "
+
+ if line_number in selected_lines:
+ t = "class:history-line,selected"
+ else:
+ t = "class:history-line"
+
+ if line_number == current_lineno:
+ t = t + ",current"
+
+ result.append((t, char))
+ result.append(("", "\n"))
+
+ return result
+
+
+class ResultMargin(Margin):
+ """
+ The margin to be shown in the result pane.
+ """
+
+ def __init__(self, history: PythonHistory) -> None:
+ self.history_mapping = history.history_mapping
+ self.history_buffer = history.history_buffer
+
+ def get_width(self, get_ui_content: Callable[[], UIContent]) -> int:
+ return 2
+
+ def create_margin(
+ self, window_render_info: WindowRenderInfo, width: int, height: int
+ ) -> StyleAndTextTuples:
+ document = self.history_buffer.document
+
+ current_lineno = document.cursor_position_row
+ offset = (
+ self.history_mapping.result_line_offset
+ ) # original_document.cursor_position_row
+
+ visible_line_to_input_line = window_render_info.visible_line_to_input_line
+
+ result: StyleAndTextTuples = []
+
+ for y in range(height):
+ line_number = visible_line_to_input_line.get(y)
+
+ if (
+ line_number is None
+ or line_number < offset
+ or line_number >= offset + len(self.history_mapping.selected_lines)
+ ):
+ t = ""
+ elif line_number == current_lineno:
+ t = "class:history-line,selected,current"
+ else:
+ t = "class:history-line,selected"
+
+ result.append((t, " "))
+ result.append(("", "\n"))
+
+ return result
+
+ def invalidation_hash(self, document: Document) -> int:
+ return document.cursor_position_row
+
+
+class GrayExistingText(Processor):
+ """
+ Turn the existing input, before and after the inserted code gray.
+ """
+
+ 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: TransformationInput
+ ) -> Transformation:
+ lineno = transformation_input.lineno
+ fragments = transformation_input.fragments
+
+ if lineno < self._lines_before or lineno >= self._lines_before + len(
+ self.history_mapping.selected_lines
+ ):
+ text = fragment_list_to_text(fragments)
+ return Transformation(fragments=[("class:history.existing-input", text)])
+ else:
+ return Transformation(fragments=fragments)
+
+
+class HistoryMapping:
+ """
+ Keep a list of all the lines from the history and the selected lines.
+ """
+
+ 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[int] = set()
+
+ # Process history.
+ history_strings = python_history.get_strings()
+ history_lines: list[str] = []
+
+ for entry_nr, entry in list(enumerate(history_strings))[-HISTORY_COUNT:]:
+ self.lines_starting_new_entries.add(len(history_lines))
+
+ for line in entry.splitlines():
+ history_lines.append(line)
+
+ if len(history_strings) > HISTORY_COUNT:
+ history_lines[0] = (
+ "# *** History has been truncated to %s lines ***" % HISTORY_COUNT
+ )
+
+ self.history_lines = history_lines
+ self.concatenated_history = "\n".join(history_lines)
+
+ # Line offset.
+ if self.original_document.text_before_cursor:
+ self.result_line_offset = self.original_document.cursor_position_row + 1
+ else:
+ self.result_line_offset = 0
+
+ def get_new_document(self, cursor_pos: int | None = None) -> Document:
+ """
+ Create a `Document` instance that contains the resulting text.
+ """
+ lines = []
+
+ # Original text, before cursor.
+ if self.original_document.text_before_cursor:
+ lines.append(self.original_document.text_before_cursor)
+
+ # Selected entries from the history.
+ for line_no in sorted(self.selected_lines):
+ lines.append(self.history_lines[line_no])
+
+ # Original text, after cursor.
+ if self.original_document.text_after_cursor:
+ lines.append(self.original_document.text_after_cursor)
+
+ # Create `Document` with cursor at the right position.
+ text = "\n".join(lines)
+ if cursor_pos is not None and cursor_pos > len(text):
+ cursor_pos = len(text)
+ return Document(text, cursor_pos)
+
+ 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: PythonHistory) -> None:
+ "Display/hide help."
+ help_buffer_control = history.history_layout.help_buffer_control
+
+ if history.app.layout.current_control == help_buffer_control:
+ history.app.layout.focus_previous()
+ else:
+ history.app.layout.current_control = help_buffer_control
+
+
+def _select_other_window(history: PythonHistory) -> None:
+ "Toggle focus between left/right window."
+ current_buffer = history.app.current_buffer
+ layout = history.history_layout.layout
+
+ if current_buffer == history.history_buffer:
+ layout.current_control = history.history_layout.default_buffer_control
+
+ elif current_buffer == history.default_buffer:
+ layout.current_control = history.history_layout.history_buffer_control
+
+
+def create_key_bindings(
+ history: PythonHistory,
+ python_input: PythonInput,
+ history_mapping: HistoryMapping,
+) -> KeyBindings:
+ """
+ Key bindings.
+ """
+ bindings = KeyBindings()
+ handle = bindings.add
+
+ @handle(" ", filter=has_focus(history.history_buffer))
+ def _(event: E) -> None:
+ """
+ Space: select/deselect line from history pane.
+ """
+ b = event.current_buffer
+ line_no = b.document.cursor_position_row
+
+ if not history_mapping.history_lines:
+ # If we've no history, then nothing to do
+ return
+
+ if line_no in history_mapping.selected_lines:
+ # Remove line.
+ history_mapping.selected_lines.remove(line_no)
+ history_mapping.update_default_buffer()
+ else:
+ # Add line.
+ history_mapping.selected_lines.add(line_no)
+ history_mapping.update_default_buffer()
+
+ # Update cursor position
+ default_buffer = history.default_buffer
+ default_lineno = (
+ sorted(history_mapping.selected_lines).index(line_no)
+ + history_mapping.result_line_offset
+ )
+ default_buffer.cursor_position = (
+ default_buffer.document.translate_row_col_to_index(default_lineno, 0)
+ )
+
+ # Also move the cursor to the next line. (This way they can hold
+ # space to select a region.)
+ b.cursor_position = b.document.translate_row_col_to_index(line_no + 1, 0)
+
+ @handle(" ", filter=has_focus(DEFAULT_BUFFER))
+ @handle("delete", filter=has_focus(DEFAULT_BUFFER))
+ @handle("c-h", filter=has_focus(DEFAULT_BUFFER))
+ def _(event: E) -> None:
+ """
+ Space: remove line from default pane.
+ """
+ b = event.current_buffer
+ line_no = b.document.cursor_position_row - history_mapping.result_line_offset
+
+ if line_no >= 0:
+ try:
+ history_lineno = sorted(history_mapping.selected_lines)[line_no]
+ except IndexError:
+ pass # When `selected_lines` is an empty set.
+ else:
+ history_mapping.selected_lines.remove(history_lineno)
+
+ history_mapping.update_default_buffer()
+
+ help_focussed = has_focus(history.help_buffer)
+ main_buffer_focussed = has_focus(history.history_buffer) | has_focus(
+ history.default_buffer
+ )
+
+ @handle("tab", filter=main_buffer_focussed)
+ @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: E) -> None:
+ "Select other window."
+ _select_other_window(history)
+
+ @handle("f4")
+ def _(event: E) -> None:
+ "Switch between Emacs/Vi mode."
+ python_input.vi_mode = not python_input.vi_mode
+
+ @handle("f1")
+ 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: 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: E) -> None:
+ "Cancel and go back."
+ event.app.exit(result=None)
+
+ @handle("enter", filter=main_buffer_focussed)
+ 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: E) -> None:
+ "Suspend to background."
+ event.app.suspend_to_background()
+
+ return bindings
+
+
+class PythonHistory:
+ 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 returns the selected lines.
+ """
+ self.python_input = python_input
+
+ history_mapping = HistoryMapping(self, python_input.history, original_document)
+ self.history_mapping = history_mapping
+
+ document = Document(history_mapping.concatenated_history)
+ document = Document(
+ document.text,
+ cursor_position=document.cursor_position
+ + 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=accept_handler,
+ read_only=True,
+ )
+
+ self.default_buffer = Buffer(
+ name=DEFAULT_BUFFER,
+ document=history_mapping.get_new_document(),
+ on_cursor_position_changed=self._default_buffer_pos_changed,
+ read_only=True,
+ )
+
+ self.help_buffer = Buffer(document=Document(HELP_TEXT, 0), read_only=True)
+
+ self.history_layout = HistoryLayout(self)
+
+ self.app: Application[str] = Application(
+ layout=self.history_layout.layout,
+ full_screen=True,
+ style=python_input._current_style,
+ mouse_support=Condition(lambda: python_input.enable_mouse_support),
+ key_bindings=create_key_bindings(self, python_input, history_mapping),
+ )
+
+ def _default_buffer_pos_changed(self, _: Buffer) -> None:
+ """When the cursor changes in the default buffer. Synchronize with
+ history buffer."""
+ # Only when this buffer has the focus.
+ if self.app.current_buffer == self.default_buffer:
+ try:
+ line_no = (
+ self.default_buffer.document.cursor_position_row
+ - self.history_mapping.result_line_offset
+ )
+
+ if line_no < 0: # When the cursor is above the inserted region.
+ raise IndexError
+
+ history_lineno = sorted(self.history_mapping.selected_lines)[line_no]
+ except IndexError:
+ pass
+ else:
+ self.history_buffer.cursor_position = (
+ self.history_buffer.document.translate_row_col_to_index(
+ history_lineno, 0
+ )
+ )
+
+ 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
+
+ if line_no in self.history_mapping.selected_lines:
+ default_lineno = (
+ sorted(self.history_mapping.selected_lines).index(line_no)
+ + self.history_mapping.result_line_offset
+ )
+
+ self.default_buffer.cursor_position = (
+ self.default_buffer.document.translate_row_col_to_index(
+ default_lineno, 0
+ )
+ )
diff --git a/ptpython/ipython.py b/ptpython/ipython.py
new file mode 100644
index 0000000..ad0516a
--- /dev/null
+++ b/ptpython/ipython.py
@@ -0,0 +1,339 @@
+"""
+
+Adaptor for using the input system of `prompt_toolkit` with the IPython
+backend.
+
+This gives a powerful interactive shell that has a nice user interface, but
+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.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,
+ WordCompleter,
+)
+from prompt_toolkit.contrib.completers import SystemCompleter
+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 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 .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"]
+
+
+class IPythonPrompt(PromptStyle):
+ """
+ Style for IPython >5.0, use the prompt_toolkit tokens directly.
+ """
+
+ def __init__(self, prompts):
+ self.prompts = prompts
+
+ def in_prompt(self) -> AnyFormattedText:
+ return PygmentsTokens(self.prompts.in_prompt_tokens())
+
+ def in2_prompt(self, width: int) -> AnyFormattedText:
+ return PygmentsTokens(self.prompts.continuation_prompt_tokens())
+
+ def out_prompt(self) -> AnyFormattedText:
+ return []
+
+
+class IPythonValidator(PythonValidator):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.isp = TransformerManager()
+
+ def validate(self, document: Document) -> None:
+ document = Document(text=self.isp.transform_cell(document.text))
+ super().validate(document)
+
+
+def create_ipython_grammar():
+ """
+ Return compiled IPython grammar.
+ """
+ return compile(
+ r"""
+ \s*
+ (
+ (?P<percent>%)(
+ (?P<magic>pycat|run|loadpy|load) \s+ (?P<py_filename>[^\s]+) |
+ (?P<magic>cat) \s+ (?P<filename>[^\s]+) |
+ (?P<magic>pushd|cd|ls) \s+ (?P<directory>[^\s]+) |
+ (?P<magic>pdb) \s+ (?P<pdb_arg>[^\s]+) |
+ (?P<magic>autocall) \s+ (?P<autocall_arg>[^\s]+) |
+ (?P<magic>time|timeit|prun) \s+ (?P<python>.+) |
+ (?P<magic>psource|pfile|pinfo|pinfo2) \s+ (?P<python>.+) |
+ (?P<magic>system) \s+ (?P<system>.+) |
+ (?P<magic>unalias) \s+ (?P<alias_name>.+) |
+ (?P<magic>[^\s]+) .* |
+ ) .* |
+ !(?P<system>.+) |
+ (?![%!]) (?P<python>.+)
+ )
+ \s*
+ """
+ )
+
+
+def create_completer(
+ get_globals,
+ get_locals,
+ magics_manager,
+ alias_manager,
+ get_enable_dictionary_completion,
+):
+ g = create_ipython_grammar()
+
+ return GrammarCompleter(
+ g,
+ {
+ "python": PythonCompleter(
+ get_globals, get_locals, get_enable_dictionary_completion
+ ),
+ "magic": MagicsCompleter(magics_manager),
+ "alias_name": AliasCompleter(alias_manager),
+ "pdb_arg": WordCompleter(["on", "off"], ignore_case=True),
+ "autocall_arg": WordCompleter(["0", "1", "2"], ignore_case=True),
+ "py_filename": PathCompleter(
+ only_directories=False, file_filter=lambda name: name.endswith(".py")
+ ),
+ "filename": PathCompleter(only_directories=False),
+ "directory": PathCompleter(only_directories=True),
+ "system": SystemCompleter(),
+ },
+ )
+
+
+def create_lexer():
+ g = create_ipython_grammar()
+
+ return GrammarLexer(
+ g,
+ lexers={
+ "percent": SimpleLexer("class:pygments.operator"),
+ "magic": SimpleLexer("class:pygments.keyword"),
+ "filename": SimpleLexer("class:pygments.name"),
+ "python": PygmentsLexer(PythonLexer),
+ "system": PygmentsLexer(BashLexer),
+ },
+ )
+
+
+class MagicsCompleter(Completer):
+ def __init__(self, magics_manager):
+ self.magics_manager = magics_manager
+
+ def get_completions(
+ self, document: Document, complete_event: CompleteEvent
+ ) -> Iterable[Completion]:
+ text = document.text_before_cursor.lstrip()
+
+ for m in sorted(self.magics_manager.magics["line"]):
+ if m.startswith(text):
+ yield Completion("%s" % m, -len(text))
+
+
+class AliasCompleter(Completer):
+ def __init__(self, alias_manager):
+ self.alias_manager = alias_manager
+
+ def get_completions(
+ self, document: Document, complete_event: CompleteEvent
+ ) -> Iterable[Completion]:
+ text = document.text_before_cursor.lstrip()
+ # aliases = [a for a, _ in self.alias_manager.aliases]
+ aliases = self.alias_manager.aliases
+
+ for a, cmd in sorted(aliases, key=lambda a: a[0]):
+ if a.startswith(text):
+ yield Completion("%s" % a, -len(text), display_meta=cmd)
+
+
+class IPythonInput(PythonInput):
+ """
+ Override our `PythonCommandLineInterface` to add IPython specific stuff.
+ """
+
+ def __init__(self, ipython_shell, *a, **kw):
+ kw["_completer"] = create_completer(
+ kw["get_globals"],
+ kw["get_globals"],
+ ipython_shell.magics_manager,
+ ipython_shell.alias_manager,
+ lambda: self.enable_dictionary_completion,
+ )
+ kw["_lexer"] = create_lexer()
+ kw["_validator"] = IPythonValidator(get_compiler_flags=self.get_compiler_flags)
+
+ super().__init__(*a, **kw)
+ self.ipython_shell = ipython_shell
+
+ self.all_prompt_styles["ipython"] = IPythonPrompt(ipython_shell.prompts)
+ self.prompt_style = "ipython"
+
+ # UI style for IPython. Add tokens that are used by IPython>5.0
+ style_dict = {}
+ style_dict.update(default_ui_style)
+ style_dict.update(
+ {
+ "pygments.prompt": "#009900",
+ "pygments.prompt-num": "#00ff00 bold",
+ "pygments.out-prompt": "#990000",
+ "pygments.out-prompt-num": "#ff0000 bold",
+ }
+ )
+
+ 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):
+ """
+ Override the `InteractiveShellEmbed` from IPython, to replace the front-end
+ with our input shell.
+
+ :param configure: Callable for configuring the repl.
+ """
+
+ def __init__(self, *a, **kw):
+ vi_mode = kw.pop("vi_mode", False)
+ history_filename = kw.pop("history_filename", None)
+ configure = kw.pop("configure", None)
+ title = kw.pop("title", None)
+
+ # Don't ask IPython to confirm for exit. We have our own exit prompt.
+ self.confirm_exit = False
+
+ super().__init__(*a, **kw)
+
+ def get_globals():
+ return self.user_ns
+
+ python_input = IPythonInput(
+ self,
+ get_globals=get_globals,
+ vi_mode=vi_mode,
+ history_filename=history_filename,
+ )
+
+ if title:
+ python_input.terminal_title = title
+
+ if configure:
+ configure(python_input)
+ python_input.prompt_style = "ipython" # Don't take from config.
+
+ self.python_input = python_input
+
+ def prompt_for_code(self) -> str:
+ try:
+ return self.python_input.app.run()
+ except KeyboardInterrupt:
+ self.python_input.default_buffer.document = Document()
+ return ""
+
+
+def initialize_extensions(shell, extensions):
+ """
+ Partial copy of `InteractiveShellApp.init_extensions` from IPython.
+ """
+ try:
+ iter(extensions)
+ except TypeError:
+ pass # no extensions found
+ else:
+ for ext in extensions:
+ try:
+ shell.extension_manager.load_extension(ext)
+ except:
+ warn(
+ "Error in loading extension: %s" % ext
+ + "\nCheck your config files in %s"
+ % ipy_utils.path.get_ipython_dir()
+ )
+ 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.
+ """
+ config = kwargs.get("config")
+ header = kwargs.pop("header", "")
+ compile_flags = kwargs.pop("compile_flags", None)
+ if config is None:
+ config = load_default_config()
+ config.InteractiveShellEmbed = config.TerminalInteractiveShell
+ 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
new file mode 100644
index 0000000..d7bb575
--- /dev/null
+++ b/ptpython/key_bindings.py
@@ -0,0 +1,337 @@
+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 (
+ Condition,
+ emacs_insert_mode,
+ emacs_mode,
+ has_focus,
+ has_selection,
+ vi_insert_mode,
+)
+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() -> bool:
+ """
+ When the 'tab' key is pressed with only whitespace character before the
+ cursor, do autocompletion. Otherwise, insert indentation.
+
+ Except for the first character at the first line. Then always do a
+ completion. It doesn't make sense to start the first line with
+ indentation.
+ """
+ b = get_app().current_buffer
+ before_cursor = b.document.current_line_before_cursor
+
+ return bool(b.text and (not before_cursor or before_cursor.isspace()))
+
+
+def load_python_bindings(python_input: PythonInput) -> KeyBindings:
+ """
+ Custom key bindings.
+ """
+ bindings = KeyBindings()
+
+ sidebar_visible = Condition(lambda: python_input.show_sidebar)
+ handle = bindings.add
+
+ @handle("c-l")
+ 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: E) -> None:
+ """
+ Suspend.
+ """
+ if python_input.enable_system_bindings:
+ event.app.suspend_to_background()
+
+ # Delete word before cursor, but use all Python symbols as separators
+ # (WORD=False).
+ handle("c-w")(get_by_name("backward-kill-word"))
+
+ @handle("f2")
+ def _(event: E) -> None:
+ """
+ Show/hide sidebar.
+ """
+ python_input.show_sidebar = not python_input.show_sidebar
+ if python_input.show_sidebar:
+ event.app.layout.focus(python_input.ptpython_layout.sidebar)
+ else:
+ event.app.layout.focus_last()
+
+ @handle("f3")
+ def _(event: E) -> None:
+ """
+ Select from the history.
+ """
+ python_input.enter_history()
+
+ @handle("f4")
+ def _(event: E) -> None:
+ """
+ Toggle between Vi and Emacs mode.
+ """
+ python_input.vi_mode = not python_input.vi_mode
+
+ @handle("f6")
+ def _(event: E) -> None:
+ """
+ Enable/Disable paste mode.
+ """
+ python_input.paste_mode = not python_input.paste_mode
+
+ @handle(
+ "tab", filter=~sidebar_visible & ~has_selection & tab_should_insert_whitespace
+ )
+ def _(event: E) -> None:
+ """
+ When tab should insert whitespace, do that instead of completion.
+ """
+ event.app.current_buffer.insert_text(" ")
+
+ @Condition
+ def is_multiline() -> bool:
+ return document_is_multiline_python(python_input.default_buffer.document)
+
+ @handle(
+ "enter",
+ filter=~sidebar_visible
+ & ~has_selection
+ & (vi_insert_mode | emacs_insert_mode)
+ & has_focus(DEFAULT_BUFFER)
+ & ~is_multiline,
+ )
+ @handle(Keys.Escape, Keys.Enter, filter=~sidebar_visible & emacs_mode)
+ def _(event: E) -> None:
+ """
+ Accept input (for single line input).
+ """
+ b = event.current_buffer
+
+ if b.validate():
+ # When the cursor is at the end, and we have an empty line:
+ # drop the empty lines, but return the value.
+ b.document = Document(
+ text=b.text.rstrip(), cursor_position=len(b.text.rstrip())
+ )
+
+ b.validate_and_handle()
+
+ @handle(
+ "enter",
+ filter=~sidebar_visible
+ & ~has_selection
+ & (vi_insert_mode | emacs_insert_mode)
+ & has_focus(DEFAULT_BUFFER)
+ & is_multiline,
+ )
+ def _(event: E) -> None:
+ """
+ Behaviour of the Enter key.
+
+ Auto indent after newline/Enter.
+ (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: 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 "\n" not in text)
+
+ if python_input.paste_mode:
+ # In paste mode, always insert text.
+ b.insert_text("\n")
+
+ elif at_the_end(b) and b.document.text.replace(" ", "").endswith(
+ "\n" * (empty_lines_required - 1)
+ ):
+ # When the cursor is at the end, and we have an empty line:
+ # drop the empty lines, but return the value.
+ if b.validate():
+ b.document = Document(
+ text=b.text.rstrip(), cursor_position=len(b.text.rstrip())
+ )
+
+ b.validate_and_handle()
+ else:
+ auto_newline(b)
+
+ @handle(
+ "c-d",
+ filter=~sidebar_visible
+ & has_focus(python_input.default_buffer)
+ & Condition(
+ lambda:
+ # The current buffer is empty.
+ not get_app().current_buffer.text
+ ),
+ )
+ def _(event: E) -> None:
+ """
+ Override Control-D exit, to ask for confirmation.
+ """
+ if python_input.confirm_exit:
+ # Show exit confirmation and focus it (focusing is important for
+ # making sure the default buffer key bindings are not active).
+ python_input.show_exit_confirmation = True
+ python_input.app.layout.focus(
+ python_input.ptpython_layout.exit_confirmation
+ )
+ else:
+ event.app.exit(exception=EOFError)
+
+ @handle("c-c", filter=has_focus(python_input.default_buffer))
+ 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: PythonInput) -> KeyBindings:
+ """
+ Load bindings for the navigation in the sidebar.
+ """
+ bindings = KeyBindings()
+
+ handle = bindings.add
+ sidebar_visible = Condition(lambda: python_input.show_sidebar)
+
+ @handle("up", filter=sidebar_visible)
+ @handle("c-p", filter=sidebar_visible)
+ @handle("k", filter=sidebar_visible)
+ def _(event: E) -> None:
+ "Go to previous option."
+ python_input.selected_option_index = (
+ python_input.selected_option_index - 1
+ ) % python_input.option_count
+
+ @handle("down", filter=sidebar_visible)
+ @handle("c-n", filter=sidebar_visible)
+ @handle("j", filter=sidebar_visible)
+ def _(event: E) -> None:
+ "Go to next option."
+ python_input.selected_option_index = (
+ python_input.selected_option_index + 1
+ ) % python_input.option_count
+
+ @handle("right", filter=sidebar_visible)
+ @handle("l", filter=sidebar_visible)
+ @handle(" ", filter=sidebar_visible)
+ 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: E) -> None:
+ "Select previous value for current option."
+ option = python_input.selected_option
+ option.activate_previous()
+
+ @handle("c-c", filter=sidebar_visible)
+ @handle("c-d", filter=sidebar_visible)
+ @handle("c-d", filter=sidebar_visible)
+ @handle("enter", filter=sidebar_visible)
+ @handle("escape", filter=sidebar_visible)
+ 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: PythonInput) -> KeyBindings:
+ """
+ Handle yes/no key presses when the exit confirmation is shown.
+ """
+ bindings = KeyBindings()
+
+ handle = bindings.add
+ confirmation_visible = Condition(lambda: python_input.show_exit_confirmation)
+
+ @handle("y", filter=confirmation_visible)
+ @handle("Y", filter=confirmation_visible)
+ @handle("enter", filter=confirmation_visible)
+ @handle("c-d", filter=confirmation_visible)
+ def _(event: E) -> None:
+ """
+ Really quit.
+ """
+ event.app.exit(exception=EOFError, style="class:exiting")
+
+ @handle(Keys.Any, filter=confirmation_visible)
+ def _(event: E) -> None:
+ """
+ Cancel exit.
+ """
+ python_input.show_exit_confirmation = False
+ python_input.app.layout.focus_previous()
+
+ return bindings
+
+
+def auto_newline(buffer: Buffer) -> None:
+ r"""
+ Insert \n at the cursor position. Also add necessary padding.
+ """
+ insert_text = buffer.insert_text
+
+ if buffer.document.current_line_after_cursor:
+ # When we are in the middle of a line. Always insert a newline.
+ insert_text("\n")
+ else:
+ # Go to new line, but also add indentation.
+ current_line = buffer.document.current_line_before_cursor.rstrip()
+ insert_text("\n")
+
+ # Unident if the last line ends with 'pass', remove four spaces.
+ unindent = current_line.rstrip().endswith(" pass")
+
+ # Copy whitespace from current line
+ current_line2 = current_line[4:] if unindent else current_line
+
+ for c in current_line2:
+ if c.isspace():
+ insert_text(c)
+ else:
+ break
+
+ # If the last line ends with a colon, add four extra spaces.
+ if current_line[-1:] == ":":
+ for x in range(4):
+ insert_text(" ")
diff --git a/ptpython/layout.py b/ptpython/layout.py
new file mode 100644
index 0000000..2c1ec15
--- /dev/null
+++ b/ptpython/layout.py
@@ -0,0 +1,773 @@
+"""
+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, Any
+
+from prompt_toolkit.application import get_app
+from prompt_toolkit.enums import DEFAULT_BUFFER, SEARCH_BUFFER
+from prompt_toolkit.filters import (
+ Condition,
+ has_focus,
+ is_done,
+ renderer_height_is_known,
+)
+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,
+ FloatContainer,
+ HSplit,
+ ScrollOffsets,
+ VSplit,
+ Window,
+)
+from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
+from prompt_toolkit.layout.dimension import AnyDimension, Dimension
+from prompt_toolkit.layout.layout import Layout
+from prompt_toolkit.layout.margins import PromptMargin
+from prompt_toolkit.layout.menus import CompletionsMenu, MultiColumnCompletionsMenu
+from prompt_toolkit.layout.processors import (
+ AppendAutoSuggestion,
+ ConditionalProcessor,
+ DisplayMultipleCursors,
+ HighlightIncrementalSearchProcessor,
+ HighlightMatchingBracketProcessor,
+ HighlightSelectionProcessor,
+ Processor,
+ TabsProcessor,
+)
+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 (
+ ArgToolbar,
+ CompletionsToolbar,
+ SearchToolbar,
+ SystemToolbar,
+ ValidationToolbar,
+)
+
+from .filters import HasSignature, ShowDocstring, ShowSidebar, ShowSignature
+from .prompt_style import PromptStyle
+from .utils import if_mousedown
+
+if TYPE_CHECKING:
+ from .python_input import OptionCategory, PythonInput
+
+__all__ = ["PtPythonLayout", "CompletionVisualisation"]
+
+
+class CompletionVisualisation(Enum):
+ "Visualisation method for the completions."
+
+ NONE = "none"
+ POP_UP = "pop-up"
+ MULTI_COLUMN = "multi-column"
+ TOOLBAR = "toolbar"
+
+
+def show_completions_toolbar(python_input: PythonInput) -> Condition:
+ return Condition(
+ lambda: python_input.completion_visualisation == CompletionVisualisation.TOOLBAR
+ )
+
+
+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:
+ return Condition(
+ lambda: python_input.completion_visualisation
+ == CompletionVisualisation.MULTI_COLUMN
+ )
+
+
+def python_sidebar(python_input: PythonInput) -> Window:
+ """
+ Create the `Layout` for the sidebar with the configurable options.
+ """
+
+ def get_text_fragments() -> StyleAndTextTuples:
+ tokens: StyleAndTextTuples = []
+
+ def append_category(category: OptionCategory[Any]) -> None:
+ tokens.extend(
+ [
+ ("class:sidebar", " "),
+ ("class:sidebar.title", " %-36s" % category.title),
+ ("class:sidebar", "\n"),
+ ]
+ )
+
+ def append(index: int, label: str, status: str) -> None:
+ selected = index == python_input.selected_option_index
+
+ @if_mousedown
+ def select_item(mouse_event: MouseEvent) -> None:
+ python_input.selected_option_index = index
+
+ @if_mousedown
+ def goto_next(mouse_event: MouseEvent) -> None:
+ "Select item and go to next value."
+ python_input.selected_option_index = index
+ option = python_input.selected_option
+ option.activate_next()
+
+ sel = ",selected" if selected else ""
+
+ tokens.append(("class:sidebar" + sel, " >" if selected else " "))
+ tokens.append(("class:sidebar.label" + sel, "%-24s" % label, select_item))
+ tokens.append(("class:sidebar.status" + sel, " ", select_item))
+ tokens.append(("class:sidebar.status" + sel, "%s" % status, goto_next))
+
+ if selected:
+ tokens.append(("[SetCursorPosition]", ""))
+
+ tokens.append(
+ ("class:sidebar.status" + sel, " " * (13 - len(status)), goto_next)
+ )
+ tokens.append(("class:sidebar", "<" if selected else ""))
+ tokens.append(("class:sidebar", "\n"))
+
+ i = 0
+ for category in python_input.options:
+ append_category(category)
+
+ for option in category.options:
+ append(i, option.title, str(option.get_current_value()))
+ i += 1
+
+ tokens.pop() # Remove last newline.
+
+ return tokens
+
+ class Control(FormattedTextControl):
+ def move_cursor_down(self) -> None:
+ python_input.selected_option_index += 1
+
+ def move_cursor_up(self) -> None:
+ python_input.selected_option_index -= 1
+
+ return Window(
+ Control(get_text_fragments),
+ style="class:sidebar",
+ width=Dimension.exact(43),
+ height=Dimension(min=3),
+ scroll_offsets=ScrollOffsets(top=1, bottom=1),
+ )
+
+
+def python_sidebar_navigation(python_input: PythonInput) -> Window:
+ """
+ Create the `Layout` showing the navigation information for the sidebar.
+ """
+
+ def get_text_fragments() -> StyleAndTextTuples:
+ # Show navigation info.
+ return [
+ ("class:sidebar", " "),
+ ("class:sidebar.key", "[Arrows]"),
+ ("class:sidebar", " "),
+ ("class:sidebar.description", "Navigate"),
+ ("class:sidebar", " "),
+ ("class:sidebar.key", "[Enter]"),
+ ("class:sidebar", " "),
+ ("class:sidebar.description", "Hide menu"),
+ ]
+
+ return Window(
+ FormattedTextControl(get_text_fragments),
+ style="class:sidebar",
+ width=Dimension.exact(43),
+ height=Dimension.exact(1),
+ )
+
+
+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() -> str:
+ """
+ Return the description of the selected option.
+ """
+ i = 0
+ for category in python_input.options:
+ for option in category.options:
+ if i == python_input.selected_option_index:
+ return option.description
+ i += 1
+ return ""
+
+ def get_help_text() -> StyleAndTextTuples:
+ return [(token, get_current_description())]
+
+ return ConditionalContainer(
+ content=Window(
+ FormattedTextControl(get_help_text),
+ style=token,
+ height=Dimension(min=3),
+ wrap_lines=True,
+ ),
+ filter=ShowSidebar(python_input)
+ & Condition(lambda: python_input.show_sidebar_help)
+ & ~is_done,
+ )
+
+
+def signature_toolbar(python_input: PythonInput) -> Container:
+ """
+ Return the `Layout` for the signature.
+ """
+
+ def get_text_fragments() -> StyleAndTextTuples:
+ result: StyleAndTextTuples = []
+ append = result.append
+ Signature = "class:signature-toolbar"
+
+ if python_input.signatures:
+ sig = python_input.signatures[0] # Always take the first one.
+
+ append((Signature, " "))
+ try:
+ append((Signature, sig.name))
+ except IndexError:
+ # Workaround for #37: https://github.com/jonathanslenders/python-prompt-toolkit/issues/37
+ # See also: https://github.com/davidhalter/jedi/issues/490
+ return []
+
+ append((Signature + ",operator", "("))
+
+ got_positional_only = False
+ got_keyword_only = False
+
+ for i, p in enumerate(sig.parameters):
+ # Detect transition between positional-only and not positional-only.
+ if p.kind == ParameterKind.POSITIONAL_ONLY:
+ got_positional_only = True
+ if got_positional_only and p.kind != ParameterKind.POSITIONAL_ONLY:
+ got_positional_only = False
+ append((Signature, "/"))
+ append((Signature + ",operator", ", "))
+
+ if not got_keyword_only and p.kind == ParameterKind.KEYWORD_ONLY:
+ got_keyword_only = True
+ append((Signature, "*"))
+ append((Signature + ",operator", ", "))
+
+ sig_index = getattr(sig, "index", 0)
+
+ if i == sig_index:
+ # Note: we use `_Param.description` instead of
+ # `_Param.name`, that way we also get the '*' before args.
+ append((Signature + ",current-name", p.description))
+ else:
+ append((Signature, p.description))
+
+ if p.default:
+ # NOTE: For the jedi-based completion, the default is
+ # currently still part of the name.
+ append((Signature, f"={p.default}"))
+
+ append((Signature + ",operator", ", "))
+
+ if sig.parameters:
+ # Pop last comma
+ result.pop()
+
+ append((Signature + ",operator", ")"))
+ append((Signature, " "))
+ return result
+
+ return ConditionalContainer(
+ content=Window(
+ FormattedTextControl(get_text_fragments), height=Dimension.exact(1)
+ ),
+ # Show only when there is a signature
+ filter=HasSignature(python_input)
+ &
+ # Signature needs to be shown.
+ ShowSignature(python_input)
+ &
+ # And no sidebar is visible.
+ ~ShowSidebar(python_input)
+ &
+ # Not done yet.
+ ~is_done,
+ )
+
+
+class PythonPromptMargin(PromptMargin):
+ """
+ Create margin that displays the prompt.
+ It shows something like "In [1]:".
+ """
+
+ def __init__(self, python_input: PythonInput) -> None:
+ self.python_input = python_input
+
+ 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: 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 to_formatted_text(get_prompt_style().in2_prompt(width))
+
+ super().__init__(get_prompt, get_continuation)
+
+
+def status_bar(python_input: PythonInput) -> Container:
+ """
+ Create the `Layout` for the status bar.
+ """
+ TB = "class:status-toolbar"
+
+ @if_mousedown
+ def toggle_paste_mode(mouse_event: MouseEvent) -> None:
+ python_input.paste_mode = not python_input.paste_mode
+
+ @if_mousedown
+ def enter_history(mouse_event: MouseEvent) -> None:
+ python_input.enter_history()
+
+ def get_text_fragments() -> StyleAndTextTuples:
+ python_buffer = python_input.default_buffer
+
+ result: StyleAndTextTuples = []
+ append = result.append
+
+ append((TB, " "))
+ result.extend(get_inputmode_fragments(python_input))
+ append((TB, " "))
+
+ # Position in history.
+ append(
+ (
+ TB,
+ "%i/%i "
+ % (python_buffer.working_index + 1, len(python_buffer._working_lines)),
+ )
+ )
+
+ # Shortcuts.
+ app = get_app()
+ if (
+ not python_input.vi_mode
+ and app.current_buffer == python_input.search_buffer
+ ):
+ append((TB, "[Ctrl-G] Cancel search [Enter] Go to this position."))
+ elif bool(app.current_buffer.selection_state) and not python_input.vi_mode:
+ # Emacs cut/copy keys.
+ append((TB, "[Ctrl-W] Cut [Meta-W] Copy [Ctrl-Y] Paste [Ctrl-G] Cancel"))
+ else:
+ result.extend(
+ [
+ (TB + " class:status-toolbar.key", "[F3]", enter_history),
+ (TB, " History ", enter_history),
+ (TB + " class:status-toolbar.key", "[F6]", toggle_paste_mode),
+ (TB, " ", toggle_paste_mode),
+ ]
+ )
+
+ if python_input.paste_mode:
+ append(
+ (TB + " class:paste-mode-on", "Paste mode (on)", toggle_paste_mode)
+ )
+ else:
+ append((TB, "Paste mode", toggle_paste_mode))
+
+ return result
+
+ return ConditionalContainer(
+ content=Window(content=FormattedTextControl(get_text_fragments), style=TB),
+ filter=~is_done
+ & renderer_height_is_known
+ & Condition(
+ lambda: python_input.show_status_bar
+ and not python_input.show_exit_confirmation
+ ),
+ )
+
+
+def get_inputmode_fragments(python_input: PythonInput) -> StyleAndTextTuples:
+ """
+ Return current input mode as a list of (token, text) tuples for use in a
+ toolbar.
+ """
+ app = get_app()
+
+ @if_mousedown
+ def toggle_vi_mode(mouse_event: MouseEvent) -> None:
+ python_input.vi_mode = not python_input.vi_mode
+
+ token = "class:status-toolbar"
+ input_mode_t = "class:status-toolbar.input-mode"
+
+ mode = app.vi_state.input_mode
+ result: StyleAndTextTuples = []
+ append = result.append
+
+ if python_input.title:
+ result.extend(to_formatted_text(python_input.title))
+
+ append((input_mode_t, "[F4] ", toggle_vi_mode))
+
+ # InputMode
+ if python_input.vi_mode:
+ recording_register = app.vi_state.recording_register
+ if recording_register:
+ append((token, " "))
+ append((token + " class:record", f"RECORD({recording_register})"))
+ append((token, " - "))
+
+ if app.current_buffer.selection_state is not None:
+ if app.current_buffer.selection_state.type == SelectionType.LINES:
+ append((input_mode_t, "Vi (VISUAL LINE)", toggle_vi_mode))
+ elif app.current_buffer.selection_state.type == SelectionType.CHARACTERS:
+ append((input_mode_t, "Vi (VISUAL)", toggle_vi_mode))
+ append((token, " "))
+ elif app.current_buffer.selection_state.type == SelectionType.BLOCK:
+ append((input_mode_t, "Vi (VISUAL BLOCK)", toggle_vi_mode))
+ append((token, " "))
+ elif mode in (InputMode.INSERT, "vi-insert-multiple"):
+ append((input_mode_t, "Vi (INSERT)", toggle_vi_mode))
+ append((token, " "))
+ elif mode == InputMode.NAVIGATION:
+ append((input_mode_t, "Vi (NAV)", toggle_vi_mode))
+ append((token, " "))
+ elif mode == InputMode.REPLACE:
+ append((input_mode_t, "Vi (REPLACE)", toggle_vi_mode))
+ append((token, " "))
+ else:
+ if app.emacs_state.is_recording:
+ append((token, " "))
+ append((token + " class:record", "RECORD"))
+ append((token, " - "))
+
+ append((input_mode_t, "Emacs", toggle_vi_mode))
+ append((token, " "))
+
+ return result
+
+
+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.)
+ """
+
+ @if_mousedown
+ def toggle_sidebar(mouse_event: MouseEvent) -> None:
+ "Click handler for the menu."
+ python_input.show_sidebar = not python_input.show_sidebar
+
+ version = sys.version_info
+ tokens: StyleAndTextTuples = [
+ ("class:status-toolbar.key", "[F2]", toggle_sidebar),
+ ("class:status-toolbar", " Menu", toggle_sidebar),
+ ("class:status-toolbar", " - "),
+ (
+ "class:status-toolbar.python-version",
+ "%s %i.%i.%i"
+ % (platform.python_implementation(), version[0], version[1], version[2]),
+ ),
+ ("class:status-toolbar", " "),
+ ]
+ width = fragment_list_width(tokens)
+
+ def get_text_fragments() -> StyleAndTextTuples:
+ # Python version
+ return tokens
+
+ return ConditionalContainer(
+ content=Window(
+ FormattedTextControl(get_text_fragments),
+ style="class:status-toolbar",
+ height=Dimension.exact(1),
+ width=Dimension.exact(width),
+ ),
+ filter=~is_done
+ & renderer_height_is_known
+ & Condition(
+ lambda: python_input.show_status_bar
+ and not python_input.show_exit_confirmation
+ ),
+ )
+
+
+def create_exit_confirmation(
+ python_input: PythonInput, style: str = "class:exit-confirmation"
+) -> Container:
+ """
+ Create `Layout` for the exit message.
+ """
+
+ def get_text_fragments() -> StyleAndTextTuples:
+ # Show "Do you really want to exit?"
+ return [
+ (style, "\n %s ([y]/n) " % python_input.exit_message),
+ ("[SetCursorPosition]", ""),
+ (style, " \n"),
+ ]
+
+ visible = ~is_done & Condition(lambda: python_input.show_exit_confirmation)
+
+ return ConditionalContainer(
+ content=Window(
+ FormattedTextControl(get_text_fragments, focusable=True), style=style
+ ),
+ filter=visible,
+ )
+
+
+def meta_enter_message(python_input: PythonInput) -> Container:
+ """
+ Create the `Layout` for the 'Meta+Enter` message.
+ """
+
+ def get_text_fragments() -> StyleAndTextTuples:
+ return [("class:accept-message", " [Meta+Enter] Execute ")]
+
+ @Condition
+ def extra_condition() -> bool:
+ "Only show when..."
+ b = python_input.default_buffer
+
+ return (
+ python_input.show_meta_enter_message
+ and (
+ not b.document.is_cursor_at_the_end
+ or python_input.accept_input_on_enter is None
+ )
+ and "\n" in b.text
+ )
+
+ visible = ~is_done & has_focus(DEFAULT_BUFFER) & extra_condition
+
+ return ConditionalContainer(
+ content=Window(FormattedTextControl(get_text_fragments)), filter=visible
+ )
+
+
+class PtPythonLayout:
+ def __init__(
+ self,
+ 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_list: list[AnyContainer] = [extra_body] if extra_body else []
+ extra_toolbars = extra_toolbars or []
+
+ input_buffer_height = input_buffer_height or D(min=6)
+
+ search_toolbar = SearchToolbar(python_input.search_buffer)
+
+ 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`.
+ """
+ b = python_input.default_buffer
+
+ if python_input.signatures:
+ 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(
+ buffer=python_input.default_buffer,
+ search_buffer_control=search_toolbar.control,
+ lexer=lexer,
+ include_default_input_processors=False,
+ input_processors=[
+ ConditionalProcessor(
+ processor=HighlightIncrementalSearchProcessor(),
+ filter=has_focus(SEARCH_BUFFER)
+ | has_focus(search_toolbar.control),
+ ),
+ HighlightSelectionProcessor(),
+ DisplayMultipleCursors(),
+ TabsProcessor(),
+ # Show matching parentheses, but only while editing.
+ ConditionalProcessor(
+ processor=HighlightMatchingBracketProcessor(chars="[](){}"),
+ filter=has_focus(DEFAULT_BUFFER)
+ & ~is_done
+ & Condition(
+ lambda: python_input.highlight_matching_parenthesis
+ ),
+ ),
+ ConditionalProcessor(
+ processor=AppendAutoSuggestion(), filter=~is_done
+ ),
+ ]
+ + (extra_buffer_processors or []),
+ menu_position=menu_position,
+ # Make sure that we always see the result of an reverse-i-search:
+ preview_search=True,
+ ),
+ left_margins=[PythonPromptMargin(python_input)],
+ # Scroll offsets. The 1 at the bottom is important to make sure
+ # the cursor is never below the "Press [Meta+Enter]" message
+ # which is a float.
+ scroll_offsets=ScrollOffsets(bottom=1, left=4, right=4),
+ # As long as we're editing, prefer a minimal height of 6.
+ height=(
+ lambda: (
+ None
+ if get_app().is_done or python_input.show_exit_confirmation
+ else input_buffer_height
+ )
+ ),
+ wrap_lines=Condition(lambda: python_input.wrap_lines),
+ )
+
+ sidebar = python_sidebar(python_input)
+ self.exit_confirmation = create_exit_confirmation(python_input)
+
+ self.root_container = HSplit(
+ [
+ VSplit(
+ [
+ HSplit(
+ [
+ FloatContainer(
+ content=HSplit(
+ [create_python_input_window()] + extra_body_list
+ ),
+ floats=[
+ Float(
+ xcursor=True,
+ ycursor=True,
+ content=HSplit(
+ [
+ signature_toolbar(python_input),
+ ConditionalContainer(
+ content=CompletionsMenu(
+ scroll_offset=(
+ lambda: python_input.completion_menu_scroll_offset
+ ),
+ max_height=12,
+ ),
+ filter=show_completions_menu(
+ python_input
+ ),
+ ),
+ ConditionalContainer(
+ content=MultiColumnCompletionsMenu(),
+ filter=show_multi_column_completions_menu(
+ python_input
+ ),
+ ),
+ ]
+ ),
+ ),
+ Float(
+ left=2,
+ bottom=1,
+ content=self.exit_confirmation,
+ ),
+ Float(
+ bottom=0,
+ right=0,
+ height=1,
+ content=meta_enter_message(python_input),
+ hide_when_covering_content=True,
+ ),
+ Float(
+ bottom=1,
+ left=1,
+ right=0,
+ content=python_sidebar_help(python_input),
+ ),
+ ],
+ ),
+ ArgToolbar(),
+ search_toolbar,
+ SystemToolbar(),
+ ValidationToolbar(),
+ ConditionalContainer(
+ content=CompletionsToolbar(),
+ filter=show_completions_toolbar(python_input)
+ & ~is_done,
+ ),
+ # Docstring region.
+ ConditionalContainer(
+ content=Window(
+ height=D.exact(1),
+ char="\u2500",
+ style="class:separator",
+ ),
+ filter=HasSignature(python_input)
+ & ShowDocstring(python_input)
+ & ~is_done,
+ ),
+ ConditionalContainer(
+ content=Window(
+ BufferControl(
+ buffer=python_input.docstring_buffer,
+ lexer=SimpleLexer(style="class:docstring"),
+ # lexer=PythonLexer,
+ ),
+ height=D(max=12),
+ ),
+ filter=HasSignature(python_input)
+ & ShowDocstring(python_input)
+ & ~is_done,
+ ),
+ ]
+ ),
+ ConditionalContainer(
+ content=HSplit(
+ [
+ sidebar,
+ Window(style="class:sidebar,separator", height=1),
+ python_sidebar_navigation(python_input),
+ ]
+ ),
+ filter=ShowSidebar(python_input) & ~is_done,
+ ),
+ ]
+ )
+ ]
+ + extra_toolbars
+ + [
+ VSplit(
+ [status_bar(python_input), show_sidebar_button_info(python_input)]
+ )
+ ]
+ )
+
+ self.layout = Layout(self.root_container)
+ self.sidebar = sidebar
diff --git a/ptpython/lexer.py b/ptpython/lexer.py
new file mode 100644
index 0000000..d925e95
--- /dev/null
+++ b/ptpython/lexer.py
@@ -0,0 +1,30 @@
+from __future__ import annotations
+
+from typing import Callable
+
+from prompt_toolkit.document import Document
+from prompt_toolkit.formatted_text import StyleAndTextTuples
+from prompt_toolkit.lexers import Lexer, PygmentsLexer
+from pygments.lexers import BashLexer
+from pygments.lexers import Python3Lexer as PythonLexer
+
+__all__ = ["PtpythonLexer"]
+
+
+class PtpythonLexer(Lexer):
+ """
+ Lexer for ptpython input.
+
+ If the input starts with an exclamation mark, use a Bash lexer, otherwise,
+ use a Python 3 lexer.
+ """
+
+ def __init__(self, python_lexer: Lexer | None = None) -> None:
+ self.python_lexer = python_lexer or PygmentsLexer(PythonLexer)
+ self.system_lexer = PygmentsLexer(BashLexer)
+
+ def lex_document(self, document: Document) -> Callable[[int], StyleAndTextTuples]:
+ if document.text.startswith("!"):
+ return self.system_lexer.lex_document(document)
+
+ return self.python_lexer.lex_document(document)
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
new file mode 100644
index 0000000..96b738f
--- /dev/null
+++ b/ptpython/prompt_style.py
@@ -0,0 +1,79 @@
+from __future__ import annotations
+
+from abc import ABCMeta, abstractmethod
+from typing import TYPE_CHECKING
+
+from prompt_toolkit.formatted_text import AnyFormattedText
+
+if TYPE_CHECKING:
+ from .python_input import PythonInput
+
+__all__ = ["PromptStyle", "IPythonPrompt", "ClassicPrompt"]
+
+
+class PromptStyle(metaclass=ABCMeta):
+ """
+ Base class for all prompts.
+ """
+
+ @abstractmethod
+ def in_prompt(self) -> AnyFormattedText:
+ "Return the input tokens."
+ return []
+
+ @abstractmethod
+ def in2_prompt(self, width: int) -> AnyFormattedText:
+ """
+ Tokens for every following input line.
+
+ :param width: The available width. This is coming from the width taken
+ by `in_prompt`.
+ """
+ return []
+
+ @abstractmethod
+ def out_prompt(self) -> AnyFormattedText:
+ "Return the output tokens."
+ return []
+
+
+class IPythonPrompt(PromptStyle):
+ """
+ A prompt resembling the IPython prompt.
+ """
+
+ def __init__(self, python_input: PythonInput) -> None:
+ self.python_input = python_input
+
+ def in_prompt(self) -> AnyFormattedText:
+ return [
+ ("class:in", "In ["),
+ ("class:in.number", "%s" % self.python_input.current_statement_index),
+ ("class:in", "]: "),
+ ]
+
+ def in2_prompt(self, width: int) -> AnyFormattedText:
+ return [("class:in", "...: ".rjust(width))]
+
+ def out_prompt(self) -> AnyFormattedText:
+ return [
+ ("class:out", "Out["),
+ ("class:out.number", "%s" % self.python_input.current_statement_index),
+ ("class:out", "]:"),
+ ("", " "),
+ ]
+
+
+class ClassicPrompt(PromptStyle):
+ """
+ The classic Python prompt.
+ """
+
+ def in_prompt(self) -> AnyFormattedText:
+ return [("class:prompt", ">>> ")]
+
+ def in2_prompt(self, width: int) -> AnyFormattedText:
+ return [("class:prompt.dots", "...")]
+
+ def out_prompt(self) -> AnyFormattedText:
+ return []
diff --git a/ptpython/py.typed b/ptpython/py.typed
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/ptpython/py.typed
diff --git a/ptpython/python_input.py b/ptpython/python_input.py
new file mode 100644
index 0000000..54ddbef
--- /dev/null
+++ b/ptpython/python_input.py
@@ -0,0 +1,1121 @@
+"""
+Application for reading Python input.
+This can be used for creation of Python REPLs.
+"""
+from __future__ import annotations
+
+from asyncio import get_running_loop
+from functools import partial
+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 (
+ AutoSuggestFromHistory,
+ ConditionalAutoSuggest,
+ ThreadedAutoSuggest,
+)
+from prompt_toolkit.buffer import Buffer
+from prompt_toolkit.completion import (
+ Completer,
+ ConditionalCompleter,
+ DynamicCompleter,
+ FuzzyCompleter,
+ 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, FilterOrBool
+from prompt_toolkit.formatted_text import AnyFormattedText
+from prompt_toolkit.history import (
+ FileHistory,
+ History,
+ InMemoryHistory,
+ ThreadedHistory,
+)
+from prompt_toolkit.input import Input
+from prompt_toolkit.key_binding import (
+ ConditionalKeyBindings,
+ KeyBindings,
+ merge_key_bindings,
+)
+from prompt_toolkit.key_binding.bindings.auto_suggest import load_auto_suggest_bindings
+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 (
+ AdjustBrightnessStyleTransformation,
+ BaseStyle,
+ ConditionalStyleTransformation,
+ DynamicStyle,
+ SwapLightAndDarkStyleTransformation,
+ merge_style_transformations,
+)
+from prompt_toolkit.utils import is_windows
+from prompt_toolkit.validation import ConditionalValidator, Validator
+
+from .completer import CompletePrivateAttributes, HidePrivateCompleter, PythonCompleter
+from .history_browser import PythonHistory
+from .key_bindings import (
+ load_confirm_exit_bindings,
+ load_python_bindings,
+ load_sidebar_bindings,
+)
+from .layout import CompletionVisualisation, PtPythonLayout
+from .lexer import PtpythonLexer
+from .prompt_style import ClassicPrompt, IPythonPrompt, PromptStyle
+from .signatures import Signature, get_signatures_using_eval, get_signatures_using_jedi
+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"]
+
+
+if TYPE_CHECKING:
+ from typing_extensions import Protocol
+
+ class _SupportsLessThan(Protocol):
+ # Taken from typeshed. _T_lt is used by "sorted", which needs anything
+ # sortable.
+ def __lt__(self, __other: Any) -> bool:
+ ...
+
+
+_T_lt = TypeVar("_T_lt", bound="_SupportsLessThan")
+_T_kh = TypeVar("_T_kh", bound=Union[KeyHandlerCallable, Binding])
+
+
+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_lt]):
+ """
+ Ptpython configuration option that can be shown and modified from the
+ sidebar.
+
+ :param title: Text.
+ :param description: Text.
+ :param get_values: Callable that returns a dictionary mapping the
+ possible values to callbacks that activate these value.
+ :param get_current_value: Callable that returns the current, active value.
+ """
+
+ def __init__(
+ self,
+ title: str,
+ description: str,
+ 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[[], Mapping[_T_lt, Callable[[], object]]],
+ ) -> None:
+ self.title = title
+ self.description = description
+ self.get_current_value = get_current_value
+ self.get_values = get_values
+
+ @property
+ def values(self) -> Mapping[_T_lt, Callable[[], object]]:
+ return self.get_values()
+
+ def activate_next(self, _previous: bool = False) -> None:
+ """
+ Activate next value.
+ """
+ current = self.get_current_value()
+ options = sorted(self.values.keys())
+
+ # Get current index.
+ try:
+ index = options.index(current)
+ except ValueError:
+ index = 0
+
+ # Go to previous/next index.
+ if _previous:
+ index -= 1
+ else:
+ index += 1
+
+ # Call handler for this option.
+ next_option = options[index % len(options)]
+ self.values[next_option]()
+
+ def activate_previous(self) -> None:
+ """
+ Activate previous value.
+ """
+ self.activate_next(_previous=True)
+
+
+COLOR_DEPTHS = {
+ ColorDepth.DEPTH_1_BIT: "Monochrome",
+ ColorDepth.DEPTH_4_BIT: "ANSI Colors",
+ ColorDepth.DEPTH_8_BIT: "256 colors",
+ ColorDepth.DEPTH_24_BIT: "True color",
+}
+
+_Namespace = Dict[str, Any]
+_GetNamespace = Callable[[], _Namespace]
+
+
+class PythonInput:
+ """
+ Prompt for reading Python input.
+
+ ::
+
+ 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: _GetNamespace | None = None,
+ get_locals: _GetNamespace | None = None,
+ history_filename: str | None = None,
+ vi_mode: bool = False,
+ color_depth: ColorDepth | None = None,
+ # Input/output.
+ input: Input | None = None,
+ output: Output | None = None,
+ # For internal use.
+ 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
+
+ self.completer = _completer or PythonCompleter(
+ self.get_globals,
+ self.get_locals,
+ lambda: self.enable_dictionary_completion,
+ )
+
+ self._completer = HidePrivateCompleter(
+ # If fuzzy is enabled, first do fuzzy completion, but always add
+ # the non-fuzzy completions, if somehow the fuzzy completer didn't
+ # find them. (Due to the way the cursor position is moved in the
+ # fuzzy completer, some completions will not always be found by the
+ # fuzzy completer, but will be found with the normal completer.)
+ merge_completers(
+ [
+ ConditionalCompleter(
+ FuzzyCompleter(DynamicCompleter(lambda: self.completer)),
+ Condition(lambda: self.enable_fuzzy_completion),
+ ),
+ DynamicCompleter(lambda: self.completer),
+ ],
+ deduplicate=True,
+ ),
+ lambda: self.complete_private_attributes,
+ )
+ self._validator = _validator or PythonValidator(self.get_compiler_flags)
+ self._lexer = PtpythonLexer(_lexer)
+
+ self.history: History
+ if history_filename:
+ self.history = ThreadedHistory(FileHistory(history_filename))
+ else:
+ self.history = InMemoryHistory()
+
+ self._input_buffer_height = _input_buffer_height
+ self._extra_layout_body = _extra_layout_body
+ self._extra_toolbars = _extra_toolbars or []
+ self._extra_buffer_processors = _extra_buffer_processors or []
+
+ self.extra_key_bindings = extra_key_bindings or KeyBindings()
+
+ # Settings.
+ self.title: AnyFormattedText = ""
+ self.show_signature: bool = False
+ self.show_docstring: bool = False
+ self.show_meta_enter_message: bool = True
+ self.completion_visualisation: CompletionVisualisation = (
+ CompletionVisualisation.MULTI_COLUMN
+ )
+ self.completion_menu_scroll_offset: int = 1
+
+ self.show_line_numbers: bool = False
+ self.show_status_bar: bool = True
+ self.wrap_lines: bool = True
+ self.complete_while_typing: bool = True
+ self.paste_mode: bool = (
+ False # When True, don't insert whitespace after newline.
+ )
+ self.confirm_exit: bool = (
+ True # Ask for confirmation when Control-D is pressed.
+ )
+ self.accept_input_on_enter: int = 2 # Accept when pressing Enter 'n' times.
+ # 'None' means that meta-enter is always required.
+ self.enable_open_in_editor: bool = True
+ self.enable_system_bindings: bool = True
+ self.enable_input_validation: bool = True
+ self.enable_auto_suggest: bool = False
+ self.enable_mouse_support: bool = False
+ self.enable_history_search: bool = False # When True, like readline, going
+ # back in history will filter the
+ # history on the records starting
+ # with the current input.
+
+ self.enable_syntax_highlighting: bool = True
+ self.enable_fuzzy_completion: bool = False
+ self.enable_dictionary_completion: bool = False # Also eval-based completion.
+ self.complete_private_attributes: CompletePrivateAttributes = (
+ CompletePrivateAttributes.ALWAYS
+ )
+ self.swap_light_and_dark: bool = False
+ self.highlight_matching_parenthesis: bool = False
+ self.show_sidebar: bool = False # Currently show the sidebar.
+
+ # Pager.
+ self.enable_output_formatting: bool = False
+ self.enable_pager: bool = False
+
+ # When the sidebar is visible, also show the help text.
+ self.show_sidebar_help: bool = True
+
+ # Currently show 'Do you really want to exit?'
+ self.show_exit_confirmation: bool = False
+
+ # The title to be displayed in the terminal. (None or string.)
+ 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.)
+ self.insert_blank_line_after_input: bool = False # (For the REPL.)
+
+ # The buffers.
+ self.default_buffer = self._create_buffer()
+ 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] = {
+ "ipython": IPythonPrompt(self),
+ "classic": ClassicPrompt(),
+ }
+
+ self.get_input_prompt = lambda: self.all_prompt_styles[
+ self.prompt_style
+ ].in_prompt()
+
+ self.get_output_prompt = lambda: self.all_prompt_styles[
+ self.prompt_style
+ ].out_prompt()
+
+ #: Load styles.
+ self.code_styles: dict[str, BaseStyle] = get_all_code_styles()
+ self.ui_styles = get_all_ui_styles()
+ self._current_code_style_name: str = "default"
+ self._current_ui_style_name: str = "default"
+
+ if is_windows():
+ self._current_code_style_name = "win32"
+
+ self._current_style = self._generate_style()
+ self.color_depth: ColorDepth = color_depth or ColorDepth.default()
+
+ self.max_brightness: float = 1.0
+ self.min_brightness: float = 0.0
+
+ # Options to be configurable from the sidebar.
+ self.options = self._create_options()
+ self.selected_option_index: int = 0
+
+ #: 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] = []
+
+ # Boolean indicating whether we have a signatures thread running.
+ # (Never run more than one at the same time.)
+ self._get_signatures_thread_running: bool = False
+
+ # Get into Vi navigation mode at startup
+ self.vi_start_in_navigation_mode: bool = False
+
+ # Preserve last used Vi input mode between main loop iterations
+ self.vi_keep_last_used_mode: bool = False
+
+ self.style_transformation = merge_style_transformations(
+ [
+ ConditionalStyleTransformation(
+ SwapLightAndDarkStyleTransformation(),
+ filter=Condition(lambda: self.swap_light_and_dark),
+ ),
+ AdjustBrightnessStyleTransformation(
+ lambda: self.min_brightness, lambda: self.max_brightness
+ ),
+ ]
+ )
+ self.ptpython_layout = PtPythonLayout(
+ self,
+ lexer=DynamicLexer(
+ lambda: self._lexer
+ if self.enable_syntax_highlighting
+ else SimpleLexer()
+ ),
+ input_buffer_height=self._input_buffer_height,
+ extra_buffer_processors=self._extra_buffer_processors,
+ extra_body=self._extra_layout_body,
+ extra_toolbars=self._extra_toolbars,
+ )
+
+ # 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()
+ app.exit(result=buff.text)
+ app.pre_run_callables.append(buff.reset)
+ return True # Keep text, we call 'reset' later on.
+
+ @property
+ def option_count(self) -> int:
+ "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[Any]:
+ "Return the currently selected option."
+ i = 0
+ for category in self.options:
+ for o in category.options:
+ if i == self.selected_option_index:
+ return o
+ else:
+ i += 1
+
+ raise ValueError("Nothing selected")
+
+ def get_compiler_flags(self) -> int:
+ """
+ Give the current compiler flags by looking for _Feature instances
+ in the globals.
+ """
+ flags = 0
+
+ for value in self.get_globals().values():
+ try:
+ if isinstance(value, __future__._Feature):
+ f = value.compiler_flag
+ flags |= f
+ except BaseException:
+ # get_compiler_flags should never raise to not run into an
+ # `Unhandled exception in event loop`
+
+ # See: https://github.com/prompt-toolkit/ptpython/issues/351
+ # An exception can be raised when some objects in the globals
+ # raise an exception in a custom `__getattribute__`.
+ pass
+
+ return flags
+
+ 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):
+ ...
+ """
+ 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:
+ """
+ Install a new code color scheme.
+ """
+ self.code_styles[name] = style
+
+ def use_code_colorscheme(self, name: str) -> None:
+ """
+ Apply new colorscheme. (By name.)
+ """
+ assert name in self.code_styles
+
+ self._current_code_style_name = name
+ self._current_style = self._generate_style()
+
+ def install_ui_colorscheme(self, name: str, style: BaseStyle) -> None:
+ """
+ Install a new UI color scheme.
+ """
+ self.ui_styles[name] = style
+
+ def use_ui_colorscheme(self, name: str) -> None:
+ """
+ Apply new colorscheme. (By name.)
+ """
+ assert name in self.ui_styles
+
+ self._current_ui_style_name = name
+ self._current_style = self._generate_style()
+
+ def _use_color_depth(self, depth: ColorDepth) -> None:
+ self.color_depth = depth
+
+ def _set_min_brightness(self, value: float) -> None:
+ self.min_brightness = value
+ self.max_brightness = max(self.max_brightness, value)
+
+ def _set_max_brightness(self, value: float) -> None:
+ self.max_brightness = value
+ self.min_brightness = min(self.min_brightness, value)
+
+ def _generate_style(self) -> BaseStyle:
+ """
+ Create new Style instance.
+ (We don't want to do this on every key press, because each time the
+ renderer receives a new style class, he will redraw everything.)
+ """
+ return generate_style(
+ self.code_styles[self._current_code_style_name],
+ self.ui_styles[self._current_ui_style_name],
+ )
+
+ def _create_options(self) -> list[OptionCategory[Any]]:
+ """
+ Create a list of `Option` instances for the options sidebar.
+ """
+
+ def enable(attribute: str, value: Any = True) -> bool:
+ setattr(self, attribute, value)
+
+ # Return `True`, to be able to chain this in the lambdas below.
+ return True
+
+ def disable(attribute: str) -> bool:
+ setattr(self, attribute, False)
+ return True
+
+ def simple_option(
+ 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() -> dict[str, Callable[[], bool]]:
+ return {
+ values[1]: lambda: enable(field_name),
+ values[0]: lambda: disable(field_name),
+ }
+
+ return Option(
+ title=title,
+ description=description,
+ get_values=get_values,
+ get_current_value=get_current_value,
+ )
+
+ brightness_values = [1.0 / 20 * value for value in range(0, 21)]
+
+ return [
+ OptionCategory(
+ "Input",
+ [
+ Option(
+ title="Editing mode",
+ description="Vi or emacs key bindings.",
+ get_current_value=lambda: ["Emacs", "Vi"][self.vi_mode],
+ get_values=lambda: {
+ "Emacs": lambda: disable("vi_mode"),
+ "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.",
+ field_name="paste_mode",
+ ),
+ Option(
+ title="Complete while typing",
+ description="Generate autocompletions automatically while typing. "
+ 'Don\'t require pressing TAB. (Not compatible with "History search".)',
+ get_current_value=lambda: ["off", "on"][
+ self.complete_while_typing
+ ],
+ get_values=lambda: {
+ "on": lambda: enable("complete_while_typing")
+ and disable("enable_history_search"),
+ "off": lambda: disable("complete_while_typing"),
+ },
+ ),
+ Option(
+ title="Complete private attrs",
+ description="Show or hide private attributes in the completions. "
+ "'If no public' means: show private attributes only if no public "
+ "matches are found or if an underscore was typed.",
+ get_current_value=lambda: {
+ CompletePrivateAttributes.NEVER: "Never",
+ CompletePrivateAttributes.ALWAYS: "Always",
+ CompletePrivateAttributes.IF_NO_PUBLIC: "If no public",
+ }[self.complete_private_attributes],
+ get_values=lambda: {
+ "Never": lambda: enable(
+ "complete_private_attributes",
+ CompletePrivateAttributes.NEVER,
+ ),
+ "Always": lambda: enable(
+ "complete_private_attributes",
+ CompletePrivateAttributes.ALWAYS,
+ ),
+ "If no public": lambda: enable(
+ "complete_private_attributes",
+ CompletePrivateAttributes.IF_NO_PUBLIC,
+ ),
+ },
+ ),
+ Option(
+ title="Enable fuzzy completion",
+ description="Enable fuzzy completion.",
+ get_current_value=lambda: ["off", "on"][
+ self.enable_fuzzy_completion
+ ],
+ get_values=lambda: {
+ "on": lambda: enable("enable_fuzzy_completion"),
+ "off": lambda: disable("enable_fuzzy_completion"),
+ },
+ ),
+ Option(
+ title="Dictionary completion",
+ description="Enable experimental dictionary/list completion.\n"
+ 'WARNING: this does "eval" on fragments of\n'
+ " your Python input and is\n"
+ " potentially unsafe.",
+ get_current_value=lambda: ["off", "on"][
+ self.enable_dictionary_completion
+ ],
+ get_values=lambda: {
+ "on": lambda: enable("enable_dictionary_completion"),
+ "off": lambda: disable("enable_dictionary_completion"),
+ },
+ ),
+ Option(
+ title="History search",
+ description="When pressing the up-arrow, filter the history on input starting "
+ 'with the current text. (Not compatible with "Complete while typing".)',
+ get_current_value=lambda: ["off", "on"][
+ self.enable_history_search
+ ],
+ get_values=lambda: {
+ "on": lambda: enable("enable_history_search")
+ and disable("complete_while_typing"),
+ "off": lambda: disable("enable_history_search"),
+ },
+ ),
+ simple_option(
+ title="Mouse support",
+ description="Respond to mouse clicks and scrolling for positioning the cursor, "
+ "selecting text and scrolling through windows.",
+ field_name="enable_mouse_support",
+ ),
+ simple_option(
+ title="Confirm on exit",
+ description="Require confirmation when exiting.",
+ field_name="confirm_exit",
+ ),
+ simple_option(
+ title="Input validation",
+ description="In case of syntax errors, move the cursor to the error "
+ "instead of showing a traceback of a SyntaxError.",
+ field_name="enable_input_validation",
+ ),
+ simple_option(
+ title="Auto suggestion",
+ description="Auto suggest inputs by looking at the history. "
+ "Pressing right arrow or Ctrl-E will complete the entry.",
+ field_name="enable_auto_suggest",
+ ),
+ Option(
+ title="Accept input on enter",
+ description="Amount of ENTER presses required to execute input when the cursor "
+ "is at the end of the input. (Note that META+ENTER will always execute.)",
+ get_current_value=lambda: str(
+ self.accept_input_on_enter or "meta-enter"
+ ),
+ get_values=lambda: {
+ "2": lambda: enable("accept_input_on_enter", 2),
+ "3": lambda: enable("accept_input_on_enter", 3),
+ "4": lambda: enable("accept_input_on_enter", 4),
+ "meta-enter": lambda: enable("accept_input_on_enter", None),
+ },
+ ),
+ ],
+ ),
+ OptionCategory(
+ "Display",
+ [
+ Option(
+ title="Completions",
+ description="Visualisation to use for displaying the completions. (Multiple columns, one column, a toolbar or nothing.)",
+ get_current_value=lambda: self.completion_visualisation.value,
+ get_values=lambda: {
+ CompletionVisualisation.NONE.value: lambda: enable(
+ "completion_visualisation", CompletionVisualisation.NONE
+ ),
+ CompletionVisualisation.POP_UP.value: lambda: enable(
+ "completion_visualisation",
+ CompletionVisualisation.POP_UP,
+ ),
+ CompletionVisualisation.MULTI_COLUMN.value: lambda: enable(
+ "completion_visualisation",
+ CompletionVisualisation.MULTI_COLUMN,
+ ),
+ CompletionVisualisation.TOOLBAR.value: lambda: enable(
+ "completion_visualisation",
+ CompletionVisualisation.TOOLBAR,
+ ),
+ },
+ ),
+ Option(
+ title="Prompt",
+ description="Visualisation of the prompt. ('>>>' or 'In [1]:')",
+ get_current_value=lambda: self.prompt_style,
+ get_values=lambda: {
+ s: partial(enable, "prompt_style", s)
+ for s in self.all_prompt_styles
+ },
+ ),
+ simple_option(
+ title="Blank line after input",
+ description="Insert a blank line after the input.",
+ field_name="insert_blank_line_after_input",
+ ),
+ simple_option(
+ title="Blank line after output",
+ description="Insert a blank line after the output.",
+ field_name="insert_blank_line_after_output",
+ ),
+ simple_option(
+ title="Show signature",
+ description="Display function signatures.",
+ field_name="show_signature",
+ ),
+ simple_option(
+ title="Show docstring",
+ description="Display function docstrings.",
+ field_name="show_docstring",
+ ),
+ simple_option(
+ title="Show line numbers",
+ description="Show line numbers when the input consists of multiple lines.",
+ field_name="show_line_numbers",
+ ),
+ simple_option(
+ title="Show Meta+Enter message",
+ description="Show the [Meta+Enter] message when this key combination is required to execute commands. "
+ + "(This is the case when a simple [Enter] key press will insert a newline.",
+ field_name="show_meta_enter_message",
+ ),
+ simple_option(
+ title="Wrap lines",
+ description="Wrap lines instead of scrolling horizontally.",
+ field_name="wrap_lines",
+ ),
+ simple_option(
+ title="Show status bar",
+ description="Show the status bar at the bottom of the terminal.",
+ field_name="show_status_bar",
+ ),
+ simple_option(
+ title="Show sidebar help",
+ description="When the sidebar is visible, also show this help text.",
+ field_name="show_sidebar_help",
+ ),
+ simple_option(
+ title="Highlight parenthesis",
+ description="Highlight matching parenthesis, when the cursor is on or right after one.",
+ field_name="highlight_matching_parenthesis",
+ ),
+ simple_option(
+ title="Reformat output (black)",
+ description="Reformat outputs using Black, if possible (experimental).",
+ field_name="enable_output_formatting",
+ ),
+ simple_option(
+ title="Enable pager for output",
+ description="Use a pager for displaying outputs that don't "
+ "fit on the screen.",
+ field_name="enable_pager",
+ ),
+ ],
+ ),
+ OptionCategory(
+ "Colors",
+ [
+ simple_option(
+ title="Syntax highlighting",
+ description="Use colors for syntax highlighting",
+ field_name="enable_syntax_highlighting",
+ ),
+ simple_option(
+ title="Swap light/dark colors",
+ description="Swap light and dark colors.",
+ field_name="swap_light_and_dark",
+ ),
+ Option(
+ title="Code",
+ description="Color scheme to use for the Python code.",
+ get_current_value=lambda: self._current_code_style_name,
+ get_values=lambda: {
+ name: partial(self.use_code_colorscheme, name)
+ for name in self.code_styles
+ },
+ ),
+ Option(
+ title="User interface",
+ description="Color scheme to use for the user interface.",
+ get_current_value=lambda: self._current_ui_style_name,
+ get_values=lambda: {
+ name: partial(self.use_ui_colorscheme, name)
+ for name in self.ui_styles
+ },
+ ),
+ Option(
+ title="Color depth",
+ description="Monochrome (1 bit), 16 ANSI colors (4 bit),\n256 colors (8 bit), or 24 bit.",
+ get_current_value=lambda: COLOR_DEPTHS[self.color_depth],
+ get_values=lambda: {
+ name: partial(self._use_color_depth, depth)
+ for depth, name in COLOR_DEPTHS.items()
+ },
+ ),
+ Option(
+ title="Min brightness",
+ description="Minimum brightness for the color scheme (default=0.0).",
+ get_current_value=lambda: "%.2f" % self.min_brightness,
+ get_values=lambda: {
+ "%.2f" % value: partial(self._set_min_brightness, value)
+ for value in brightness_values
+ },
+ ),
+ Option(
+ title="Max brightness",
+ description="Maximum brightness for the color scheme (default=1.0).",
+ get_current_value=lambda: "%.2f" % self.max_brightness,
+ get_values=lambda: {
+ "%.2f" % value: partial(self._set_max_brightness, value)
+ for value in brightness_values
+ },
+ ),
+ ],
+ ),
+ ]
+
+ def _create_application(
+ self, input: Input | None, output: Output | None
+ ) -> Application[str]:
+ """
+ Create an `Application` instance.
+ """
+ return Application(
+ layout=self.ptpython_layout.layout,
+ key_bindings=merge_key_bindings(
+ [
+ load_python_bindings(self),
+ load_auto_suggest_bindings(),
+ load_sidebar_bindings(self),
+ load_confirm_exit_bindings(self),
+ ConditionalKeyBindings(
+ load_open_in_editor_bindings(),
+ Condition(lambda: self.enable_open_in_editor),
+ ),
+ # Extra key bindings should not be active when the sidebar is visible.
+ ConditionalKeyBindings(
+ self.extra_key_bindings,
+ Condition(lambda: not self.show_sidebar),
+ ),
+ ]
+ ),
+ color_depth=lambda: self.color_depth,
+ paste_mode=Condition(lambda: self.paste_mode),
+ mouse_support=Condition(lambda: self.enable_mouse_support),
+ style=DynamicStyle(lambda: self._current_style),
+ 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,
+ )
+
+ def _create_buffer(self) -> Buffer:
+ """
+ Create the `Buffer` for the Python input.
+ """
+ python_buffer = Buffer(
+ name=DEFAULT_BUFFER,
+ complete_while_typing=Condition(lambda: self.complete_while_typing),
+ enable_history_search=Condition(lambda: self.enable_history_search),
+ tempfile_suffix=".py",
+ history=self.history,
+ completer=ThreadedCompleter(self._completer),
+ validator=ConditionalValidator(
+ self._validator, Condition(lambda: self.enable_input_validation)
+ ),
+ auto_suggest=ConditionalAutoSuggest(
+ ThreadedAutoSuggest(AutoSuggestFromHistory()),
+ Condition(lambda: self.enable_auto_suggest),
+ ),
+ accept_handler=self._accept_handler,
+ on_text_changed=self._on_input_timeout,
+ )
+
+ return python_buffer
+
+ @property
+ def editing_mode(self) -> EditingMode:
+ return self.app.editing_mode
+
+ @editing_mode.setter
+ def editing_mode(self, value: EditingMode) -> None:
+ self.app.editing_mode = value
+
+ @property
+ def vi_mode(self) -> bool:
+ return self.editing_mode == EditingMode.VI
+
+ @vi_mode.setter
+ def vi_mode(self, value: bool) -> None:
+ if value:
+ self.editing_mode = EditingMode.VI
+ else:
+ self.editing_mode = EditingMode.EMACS
+
+ @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.
+ """
+
+ 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.
+ signatures = get_signatures_using_jedi(
+ document, self.get_locals(), self.get_globals()
+ )
+ if not signatures and self.enable_dictionary_completion:
+ signatures = get_signatures_using_eval(
+ document, self.get_locals(), self.get_globals()
+ )
+
+ return signatures
+
+ app = self.app
+
+ 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
+ )
+
+ # 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.docstring_buffer.reset()
+
+ app.invalidate()
+
+ if app.is_running:
+ app.create_background_task(on_timeout_task())
+
+ def on_reset(self) -> None:
+ self.signatures = []
+
+ def enter_history(self) -> None:
+ """
+ Display the history.
+ """
+ app = self.app
+ app.vi_state.input_mode = InputMode.NAVIGATION
+
+ history = PythonHistory(self, self.default_buffer.document)
+
+ import asyncio
+
+ from prompt_toolkit.application import in_terminal
+
+ async def do_in_terminal() -> None:
+ async with in_terminal():
+ result = await history.app.run_async()
+ if result is not None:
+ self.default_buffer.text = result
+
+ app.vi_state.input_mode = InputMode.INSERT
+
+ asyncio.ensure_future(do_in_terminal())
+
+ def read(self) -> str:
+ """
+ Read the input.
+
+ This will run the Python input user interface in another thread, wait
+ for input to be accepted and return that. By running the UI in another
+ thread, we avoid issues regarding possibly nested event loops.
+
+ 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.
+ def pre_run(
+ last_input_mode: InputMode = self.app.vi_state.input_mode,
+ ) -> None:
+ if self.vi_keep_last_used_mode:
+ self.app.vi_state.input_mode = last_input_mode
+
+ if not self.vi_keep_last_used_mode and self.vi_start_in_navigation_mode:
+ self.app.vi_state.input_mode = InputMode.NAVIGATION
+
+ # Run the UI.
+ while True:
+ try:
+ 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
new file mode 100644
index 0000000..fc9b9da
--- /dev/null
+++ b/ptpython/repl.py
@@ -0,0 +1,531 @@
+"""
+Utility for creating a Python repl.
+
+::
+
+ from ptpython.repl import embed
+ embed(globals(), locals(), vi_mode=False)
+
+"""
+from __future__ import annotations
+
+import asyncio
+import builtins
+import os
+import signal
+import sys
+import traceback
+import types
+import warnings
+from dis import COMPILER_FLAG_NAMES
+from 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 (
+ clear_title,
+ set_title,
+)
+from prompt_toolkit.utils import DummyContext
+from pygments.lexers import PythonTracebackLexer # noqa: F401
+
+from .printer import OutputPrinter
+from .python_input import PythonInput
+
+PyCF_ALLOW_TOP_LEVEL_AWAIT: int
+try:
+ from ast import PyCF_ALLOW_TOP_LEVEL_AWAIT # type: ignore
+except ImportError:
+ PyCF_ALLOW_TOP_LEVEL_AWAIT = 0
+
+__all__ = ["PythonRepl", "enable_deprecation_warnings", "run_config", "embed"]
+
+
+def _get_coroutine_flag() -> int | None:
+ for k, v in COMPILER_FLAG_NAMES.items():
+ if v == "COROUTINE":
+ return k
+
+ # Flag not found.
+ return None
+
+
+COROUTINE_FLAG: int | None = _get_coroutine_flag()
+
+
+def _has_coroutine_flag(code: types.CodeType) -> bool:
+ if COROUTINE_FLAG is None:
+ # Not supported on this Python version.
+ return False
+
+ return bool(code.co_flags & COROUTINE_FLAG)
+
+
+class PythonRepl(PythonInput):
+ def __init__(self, *a, **kw) -> None:
+ self._startup_paths = kw.pop("startup_paths", None)
+ super().__init__(*a, **kw)
+ self._load_start_paths()
+
+ def _load_start_paths(self) -> None:
+ "Start the Read-Eval-Print Loop."
+ if self._startup_paths:
+ for path in self._startup_paths:
+ if os.path.exists(path):
+ with open(path, "rb") as f:
+ code = compile(f.read(), path, "exec")
+ exec(code, self.get_globals(), self.get_locals())
+ else:
+ output = self.app.output
+ output.write(f"WARNING | File not found: {path}\n\n")
+
+ def run_and_show_expression(self, expression: str) -> None:
+ try:
+ # Eval.
+ try:
+ result = self.eval(expression)
+ except KeyboardInterrupt:
+ # KeyboardInterrupt doesn't inherit from Exception.
+ raise
+ except SystemExit:
+ raise
+ except 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:
+ """
+ Run the REPL loop.
+ """
+ if self.terminal_title:
+ set_title(self.terminal_title)
+
+ self._add_to_namespace()
+
+ try:
+ while True:
+ # Pull text from the user.
+ try:
+ text = self.read()
+ except EOFError:
+ return
+ except BaseException:
+ # Something went wrong while reading input.
+ # (E.g., a bug in the completer that propagates. Don't
+ # crash the REPL.)
+ traceback.print_exc()
+ continue
+
+ # Run it; display the result (or errors if applicable).
+ 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
+ we don't block the event loop. Both the input and output (which can
+ display a pager) will run in a separate thread with their own event
+ loop, this way ptpython's own event loop won't interfere with the
+ asyncio event loop from where this is called.
+
+ The "eval" however happens in the current thread, which is important.
+ (Both for control-C to work, as well as for the code to see the right
+ thread in which it was embedded).
+ """
+ loop = asyncio.get_running_loop()
+
+ if self.terminal_title:
+ set_title(self.terminal_title)
+
+ self._add_to_namespace()
+
+ try:
+ while True:
+ try:
+ # Read.
+ try:
+ text = await loop.run_in_executor(None, self.read)
+ except EOFError:
+ return
+ except BaseException:
+ # Something went wrong while reading input.
+ # (E.g., a bug in the completer that propagates. Don't
+ # crash the REPL.)
+ traceback.print_exc()
+ continue
+
+ # Eval.
+ await self.run_and_show_expression_async(text)
+
+ except KeyboardInterrupt as e:
+ # XXX: This does not yet work properly. In some situations,
+ # `KeyboardInterrupt` exceptions can end up in the event
+ # loop selector.
+ self._handle_keyboard_interrupt(e)
+ except SystemExit:
+ return
+ finally:
+ if self.terminal_title:
+ clear_title()
+ self._remove_from_namespace()
+
+ def eval(self, line: str) -> object:
+ """
+ Evaluate the line and print the result.
+ """
+ # WORKAROUND: Due to a bug in Jedi, the current directory is removed
+ # from sys.path. See: https://github.com/davidhalter/jedi/issues/1148
+ if "" not in sys.path:
+ sys.path.insert(0, "")
+
+ if line.lstrip().startswith("!"):
+ # Run as shell command
+ os.system(line[1:])
+ else:
+ # Try eval first
+ try:
+ code = self._compile_with_flags(line, "eval")
+ except SyntaxError:
+ pass
+ else:
+ # No syntax errors for eval. Do eval.
+ result = eval(code, self.get_globals(), self.get_locals())
+
+ if _has_coroutine_flag(code):
+ result = asyncio.get_running_loop().run_until_complete(result)
+
+ self._store_eval_result(result)
+ return result
+
+ # If not a valid `eval` expression, run using `exec` instead.
+ # Note that we shouldn't run this in the `except SyntaxError` block
+ # above, then `sys.exc_info()` would not report the right error.
+ # See issue: https://github.com/prompt-toolkit/ptpython/issues/435
+ code = self._compile_with_flags(line, "exec")
+ result = eval(code, self.get_globals(), self.get_locals())
+
+ if _has_coroutine_flag(code):
+ result = asyncio.get_running_loop().run_until_complete(result)
+
+ return None
+
+ async def eval_async(self, line: str) -> object:
+ """
+ Evaluate the line and print the result.
+ """
+ # WORKAROUND: Due to a bug in Jedi, the current directory is removed
+ # from sys.path. See: https://github.com/davidhalter/jedi/issues/1148
+ if "" not in sys.path:
+ sys.path.insert(0, "")
+
+ if line.lstrip().startswith("!"):
+ # Run as shell command
+ os.system(line[1:])
+ else:
+ # Try eval first
+ try:
+ code = self._compile_with_flags(line, "eval")
+ except SyntaxError:
+ pass
+ else:
+ # No syntax errors for eval. Do eval.
+ result = eval(code, self.get_globals(), self.get_locals())
+
+ if _has_coroutine_flag(code):
+ result = await result
+
+ self._store_eval_result(result)
+ return result
+
+ # If not a valid `eval` expression, compile as `exec` expression
+ # but still run with eval to get an awaitable in case of a
+ # awaitable expression.
+ code = self._compile_with_flags(line, "exec")
+ result = eval(code, self.get_globals(), self.get_locals())
+
+ if _has_coroutine_flag(code):
+ result = await result
+
+ return None
+
+ def _store_eval_result(self, result: object) -> None:
+ locals: dict[str, Any] = self.get_locals()
+ locals["_"] = locals["_%i" % self.current_statement_index] = result
+
+ def get_compiler_flags(self) -> int:
+ return super().get_compiler_flags() | PyCF_ALLOW_TOP_LEVEL_AWAIT
+
+ def _compile_with_flags(self, code: str, mode: str):
+ "Compile code with the right compiler flags."
+ return compile(
+ code,
+ "<stdin>",
+ mode,
+ flags=self.get_compiler_flags(),
+ dont_inherit=True,
+ )
+
+ def _handle_exception(self, e: BaseException) -> None:
+ self._get_output_printer().display_exception(
+ e,
+ highlight=self.enable_syntax_highlighting,
+ paginate=self.enable_pager,
+ )
+
+ def _handle_keyboard_interrupt(self, e: KeyboardInterrupt) -> None:
+ output = self.app.output
+
+ output.write("\rKeyboardInterrupt\n\n")
+ output.flush()
+
+ def _add_to_namespace(self) -> None:
+ """
+ Add ptpython built-ins to global namespace.
+ """
+ globals = self.get_globals()
+
+ # Add a 'get_ptpython', similar to 'get_ipython'
+ def get_ptpython() -> PythonInput:
+ return self
+
+ globals["get_ptpython"] = get_ptpython
+
+ def _remove_from_namespace(self) -> None:
+ """
+ Remove added symbols from the globals.
+ """
+ globals = self.get_globals()
+ del globals["get_ptpython"]
+
+ def print_paginated_formatted_text(
+ self,
+ formatted_text: Iterable[OneStyleAndTextTuple],
+ end: str = "\n",
+ ) -> None:
+ # Warning: This is mainly here backwards-compatibility. Some projects
+ # call `print_paginated_formatted_text` on the Repl object.
+ self._get_output_printer().display_style_and_text_tuples(
+ formatted_text, paginate=True
+ )
+
+
+def enable_deprecation_warnings() -> None:
+ """
+ Show deprecation warnings, when they are triggered directly by actions in
+ the REPL. This is recommended to call, before calling `embed`.
+
+ e.g. This will show an error message when the user imports the 'sha'
+ library on Python 2.7.
+ """
+ warnings.filterwarnings("default", category=DeprecationWarning, module="__main__")
+
+
+DEFAULT_CONFIG_FILE = "~/.config/ptpython/config.py"
+
+
+def run_config(repl: PythonInput, config_file: str | None = None) -> None:
+ """
+ Execute REPL config file.
+
+ :param repl: `PythonInput` instance.
+ :param config_file: Path of the configuration file.
+ """
+ explicit_config_file = config_file is not None
+
+ # Expand tildes.
+ config_file = os.path.expanduser(
+ config_file if config_file is not None else DEFAULT_CONFIG_FILE
+ )
+
+ def enter_to_continue() -> None:
+ input("\nPress ENTER to continue...")
+
+ # Check whether this file exists.
+ if not os.path.exists(config_file):
+ if explicit_config_file:
+ print(f"Impossible to read {config_file}")
+ enter_to_continue()
+ return
+
+ # Run the config file in an empty namespace.
+ try:
+ namespace: dict[str, Any] = {}
+
+ with open(config_file, "rb") as f:
+ code = compile(f.read(), config_file, "exec")
+ exec(code, namespace, namespace)
+
+ # Now we should have a 'configure' method in this namespace. We call this
+ # method with the repl as an argument.
+ if "configure" in namespace:
+ namespace["configure"](repl)
+
+ except Exception:
+ traceback.print_exc()
+ enter_to_continue()
+
+
+def embed(
+ globals=None,
+ locals=None,
+ configure: Callable[[PythonRepl], None] | None = None,
+ vi_mode: bool = False,
+ history_filename: str | None = None,
+ title: str | None = None,
+ startup_paths=None,
+ patch_stdout: bool = False,
+ return_asyncio_coroutine: bool = False,
+) -> None:
+ """
+ Call this to embed Python shell at the current point in your program.
+ It's similar to `IPython.embed` and `bpython.embed`. ::
+
+ from prompt_toolkit.contrib.repl import embed
+ embed(globals(), locals())
+
+ :param vi_mode: Boolean. Use Vi instead of Emacs key bindings.
+ :param configure: Callable that will be called with the `PythonRepl` as a first
+ argument, to trigger configuration.
+ :param title: Title to be displayed in the terminal titlebar. (None or string.)
+ :param patch_stdout: When true, patch `sys.stdout` so that background
+ threads that are printing will print nicely above the prompt.
+ """
+ # Default globals/locals
+ if globals is None:
+ globals = {
+ "__name__": "__main__",
+ "__package__": None,
+ "__doc__": None,
+ "__builtins__": builtins,
+ }
+
+ locals = locals or globals
+
+ def get_globals():
+ return globals
+
+ def get_locals():
+ return locals
+
+ # Create REPL.
+ repl = PythonRepl(
+ get_globals=get_globals,
+ get_locals=get_locals,
+ vi_mode=vi_mode,
+ history_filename=history_filename,
+ startup_paths=startup_paths,
+ )
+
+ if title:
+ repl.terminal_title = title
+
+ if configure:
+ configure(repl)
+
+ # Start repl.
+ patch_context: ContextManager[None] = (
+ patch_stdout_context() if patch_stdout else DummyContext()
+ )
+
+ if return_asyncio_coroutine:
+
+ async def coroutine() -> None:
+ with patch_context:
+ await repl.run_async()
+
+ return coroutine() # type: ignore
+ else:
+ with patch_context:
+ repl.run()
diff --git a/ptpython/signatures.py b/ptpython/signatures.py
new file mode 100644
index 0000000..d4cb98c
--- /dev/null
+++ b/ptpython/signatures.py
@@ -0,0 +1,270 @@
+"""
+Helpers for retrieving the function signature of the function call that we are
+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 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"]
+
+
+class Parameter:
+ def __init__(
+ self,
+ name: str,
+ annotation: str | None,
+ default: str | None,
+ kind: ParameterKind,
+ ) -> None:
+ self.name = name
+ self.kind = kind
+
+ self.annotation = annotation
+ self.default = default
+
+ def __repr__(self) -> str:
+ return f"Parameter(name={self.name!r})"
+
+ @property
+ def description(self) -> str:
+ """
+ Name + annotation.
+ """
+ description = self.name
+
+ if self.annotation is not None:
+ description += f": {self.annotation}"
+
+ return description
+
+
+class Signature:
+ """
+ Signature definition used wrap around both Jedi signatures and
+ python-inspect signatures.
+
+ :param index: Parameter index of the current cursor position.
+ :param bracket_start: (line, column) tuple for the open bracket that starts
+ the function call.
+ """
+
+ def __init__(
+ self,
+ name: str,
+ docstring: str,
+ parameters: Sequence[Parameter],
+ index: int | None = None,
+ returns: str = "",
+ bracket_start: tuple[int, int] = (0, 0),
+ ) -> None:
+ self.name = name
+ self.docstring = docstring
+ self.parameters = parameters
+ self.index = index
+ self.returns = returns
+ self.bracket_start = bracket_start
+
+ @classmethod
+ def from_inspect_signature(
+ cls,
+ name: str,
+ docstring: str,
+ signature: InspectSignature,
+ index: int,
+ ) -> Signature:
+ parameters = []
+
+ def get_annotation_name(annotation: object) -> str:
+ """
+ Get annotation as string from inspect signature.
+ """
+ try:
+ # In case the annotation is a class like "int", "float", ...
+ return str(annotation.__name__) # type: ignore
+ except AttributeError:
+ pass # No attribute `__name__`, e.g., in case of `List[int]`.
+
+ annotation = str(annotation)
+ if annotation.startswith("typing."):
+ annotation = annotation[len("typing:") :]
+ return annotation
+
+ for p in signature.parameters.values():
+ parameters.append(
+ Parameter(
+ name=p.name,
+ annotation=get_annotation_name(p.annotation),
+ default=repr(p.default)
+ if p.default is not inspect.Parameter.empty
+ else None,
+ kind=p.kind,
+ )
+ )
+
+ return cls(
+ name=name,
+ docstring=docstring,
+ parameters=parameters,
+ index=index,
+ returns="",
+ )
+
+ @classmethod
+ def from_jedi_signature(cls, signature: jedi.api.classes.Signature) -> Signature:
+ parameters = []
+
+ for p in signature.params:
+ if p is None:
+ # We just hit the "*".
+ continue
+
+ parameters.append(
+ Parameter(
+ name=p.to_string(), # p.name, (`to_string()` already includes the annotation).
+ annotation=None, # p.infer_annotation()
+ default=None, # p.infer_default()
+ kind=p.kind,
+ )
+ )
+
+ docstring = signature.docstring()
+ if not isinstance(docstring, str):
+ docstring = docstring.decode("utf-8")
+
+ return cls(
+ name=signature.name,
+ docstring=docstring,
+ parameters=parameters,
+ index=signature.index,
+ returns="",
+ bracket_start=signature.bracket_start,
+ )
+
+ def __repr__(self) -> str:
+ return f"Signature({self.name!r}, parameters={self.parameters!r})"
+
+
+def get_signatures_using_jedi(
+ 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.
+ if not script:
+ return []
+
+ try:
+ signatures = script.get_signatures()
+ except ValueError:
+ # e.g. in case of an invalid \\x escape.
+ signatures = []
+ except Exception:
+ # Sometimes we still get an exception (TypeError), because
+ # of probably bugs in jedi. We can silence them.
+ # See: https://github.com/davidhalter/jedi/issues/492
+ signatures = []
+ else:
+ # Try to access the params attribute just once. For Jedi
+ # signatures containing the keyword-only argument star,
+ # this will crash when retrieving it the first time with
+ # AttributeError. Every following time it works.
+ # See: https://github.com/jonathanslenders/ptpython/issues/47
+ # https://github.com/davidhalter/jedi/issues/598
+ try:
+ if signatures:
+ signatures[0].params
+ except AttributeError:
+ pass
+
+ return [Signature.from_jedi_signature(sig) for sig in signatures]
+
+
+def get_signatures_using_eval(
+ 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.
+ pos = document.cursor_position - 1
+
+ paren_mapping = {")": "(", "}": "{", "]": "["}
+ paren_stack = [
+ ")"
+ ] # Start stack with closing ')'. We are going to look for the matching open ')'.
+ comma_count = 0 # Number of comma's between start of function call and cursor pos.
+ found_start = False # Found something.
+
+ while pos >= 0:
+ char = document.text[pos]
+ if char in ")]}":
+ paren_stack.append(char)
+ elif char in "([{":
+ if not paren_stack:
+ # Open paren, while no closing paren was found. Mouse cursor is
+ # positioned in nested parentheses. Not at the "top-level" of a
+ # function call.
+ break
+ if paren_mapping[paren_stack[-1]] != char:
+ # Unmatching parentheses: syntax error?
+ break
+
+ paren_stack.pop()
+
+ if len(paren_stack) == 0:
+ found_start = True
+ break
+
+ elif char == "," and len(paren_stack) == 1:
+ comma_count += 1
+
+ pos -= 1
+
+ if not found_start:
+ return []
+
+ # We found the start of the function call. Now look for the object before
+ # this position on which we can do an 'eval' to retrieve the function
+ # object.
+ obj = DictionaryCompleter(lambda: globals, lambda: locals).eval_expression(
+ Document(document.text, cursor_position=pos), locals
+ )
+ if obj is None:
+ return []
+
+ try:
+ name = obj.__name__ # type:ignore
+ except Exception:
+ name = obj.__class__.__name__
+
+ try:
+ signature = inspect.signature(obj) # type: ignore
+ except TypeError:
+ return [] # Not a callable object.
+ except ValueError:
+ return [] # No signature found, like for build-ins like "print".
+
+ try:
+ doc = obj.__doc__ or ""
+ except:
+ doc = ""
+
+ # TODO: `index` is not yet correct when dealing with keyword-only arguments.
+ return [Signature.from_inspect_signature(name, doc, signature, index=comma_count)]
diff --git a/ptpython/style.py b/ptpython/style.py
new file mode 100644
index 0000000..c5a04e5
--- /dev/null
+++ b/ptpython/style.py
@@ -0,0 +1,175 @@
+from __future__ import annotations
+
+from prompt_toolkit.styles import BaseStyle, Style, merge_styles
+from prompt_toolkit.styles.pygments import style_from_pygments_cls
+from prompt_toolkit.utils import is_conemu_ansi, is_windows, is_windows_vt100_supported
+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]:
+ """
+ Return a mapping from style names to their classes.
+ """
+ result: dict[str, BaseStyle] = {
+ name: style_from_pygments_cls(get_style_by_name(name))
+ for name in get_all_styles()
+ }
+ result["win32"] = Style.from_dict(win32_code_style)
+ return result
+
+
+def get_all_ui_styles() -> dict[str, BaseStyle]:
+ """
+ Return a dict mapping {ui_style_name -> style_dict}.
+ """
+ return {
+ "default": Style.from_dict(default_ui_style),
+ "blue": Style.from_dict(blue_ui_style),
+ }
+
+
+def generate_style(python_style: BaseStyle, ui_style: BaseStyle) -> BaseStyle:
+ """
+ Generate Pygments Style class from two dictionaries
+ containing style rules.
+ """
+ return merge_styles([python_style, ui_style])
+
+
+# Code style for Windows consoles. They support only 16 colors,
+# so we choose a combination that displays nicely.
+win32_code_style = {
+ "pygments.comment": "#00ff00",
+ "pygments.keyword": "#44ff44",
+ "pygments.number": "",
+ "pygments.operator": "",
+ "pygments.string": "#ff44ff",
+ "pygments.name": "",
+ "pygments.name.decorator": "#ff4444",
+ "pygments.name.class": "#ff4444",
+ "pygments.name.function": "#ff4444",
+ "pygments.name.builtin": "#ff4444",
+ "pygments.name.attribute": "",
+ "pygments.name.constant": "",
+ "pygments.name.entity": "",
+ "pygments.name.exception": "",
+ "pygments.name.label": "",
+ "pygments.name.namespace": "",
+ "pygments.name.tag": "",
+ "pygments.name.variable": "",
+}
+
+
+default_ui_style = {
+ "control-character": "ansiblue",
+ # Classic prompt.
+ "prompt": "bold",
+ "prompt.dots": "noinherit",
+ # (IPython <5.0) Prompt: "In [1]:"
+ "in": "bold #008800",
+ "in.number": "",
+ # Return value.
+ "out": "#ff0000",
+ "out.number": "#ff0000",
+ # Completions.
+ "completion.builtin": "",
+ "completion.param": "#006666 italic",
+ "completion.keyword": "fg:#008800",
+ "completion.keyword fuzzymatch.inside": "fg:#008800",
+ "completion.keyword fuzzymatch.outside": "fg:#44aa44",
+ # Separator between windows. (Used above docstring.)
+ "separator": "#bbbbbb",
+ # System toolbar
+ "system-toolbar": "#22aaaa noinherit",
+ # "arg" toolbar.
+ "arg-toolbar": "#22aaaa noinherit",
+ "arg-toolbar.text": "noinherit",
+ # Signature toolbar.
+ "signature-toolbar": "bg:#44bbbb #000000",
+ "signature-toolbar current-name": "bg:#008888 #ffffff bold",
+ "signature-toolbar operator": "#000000 bold",
+ "docstring": "#888888",
+ # Validation toolbar.
+ "validation-toolbar": "bg:#440000 #aaaaaa",
+ # Status toolbar.
+ "status-toolbar": "bg:#222222 #aaaaaa",
+ "status-toolbar.title": "underline",
+ "status-toolbar.inputmode": "bg:#222222 #ffffaa",
+ "status-toolbar.key": "bg:#000000 #888888",
+ "status-toolbar key": "bg:#000000 #888888",
+ "status-toolbar.pastemodeon": "bg:#aa4444 #ffffff",
+ "status-toolbar.pythonversion": "bg:#222222 #ffffff bold",
+ "status-toolbar paste-mode-on": "bg:#aa4444 #ffffff",
+ "record": "bg:#884444 white",
+ "status-toolbar more": "#ffff44",
+ "status-toolbar.input-mode": "#ffff44",
+ # The options sidebar.
+ "sidebar": "bg:#bbbbbb #000000",
+ "sidebar.title": "bg:#668866 #ffffff",
+ "sidebar.label": "bg:#bbbbbb #222222",
+ "sidebar.status": "bg:#dddddd #000011",
+ "sidebar.label selected": "bg:#222222 #eeeeee",
+ "sidebar.status selected": "bg:#444444 #ffffff bold",
+ "sidebar.separator": "underline",
+ "sidebar.key": "bg:#bbddbb #000000 bold",
+ "sidebar.key.description": "bg:#bbbbbb #000000",
+ "sidebar.helptext": "bg:#fdf6e3 #000011",
+ # # Styling for the history layout.
+ # history.line: '',
+ # history.line.selected: 'bg:#008800 #000000',
+ # history.line.current: 'bg:#ffffff #000000',
+ # history.line.selected.current: 'bg:#88ff88 #000000',
+ # history.existinginput: '#888888',
+ # Help Window.
+ "window-border": "#aaaaaa",
+ "window-title": "bg:#bbbbbb #000000",
+ # Meta-enter message.
+ "accept-message": "bg:#ffff88 #444444",
+ # Exit confirmation.
+ "exit-confirmation": "bg:#884444 #ffffff",
+}
+
+
+# Some changes to get a bit more contrast on Windows consoles.
+# (They only support 16 colors.)
+if is_windows() and not is_conemu_ansi() and not is_windows_vt100_supported():
+ default_ui_style.update(
+ {
+ "sidebar.title": "bg:#00ff00 #ffffff",
+ "exitconfirmation": "bg:#ff4444 #ffffff",
+ "toolbar.validation": "bg:#ff4444 #ffffff",
+ "menu.completions.completion": "bg:#ffffff #000000",
+ "menu.completions.completion.current": "bg:#aaaaaa #000000",
+ }
+ )
+
+
+blue_ui_style = {}
+blue_ui_style.update(default_ui_style)
+# blue_ui_style.update({
+# # Line numbers.
+# Token.LineNumber: '#aa6666',
+#
+# # Highlighting of search matches in document.
+# Token.SearchMatch: '#ffffff bg:#4444aa',
+# Token.SearchMatch.Current: '#ffffff bg:#44aa44',
+#
+# # Highlighting of select text in document.
+# Token.SelectedText: '#ffffff bg:#6666aa',
+#
+# # Completer toolbar.
+# Token.Toolbar.Completions: 'bg:#44bbbb #000000',
+# Token.Toolbar.Completions.Arrow: 'bg:#44bbbb #000000 bold',
+# Token.Toolbar.Completions.Completion: 'bg:#44bbbb #000000',
+# Token.Toolbar.Completions.Completion.Current: 'bg:#008888 #ffffff',
+#
+# # Completer menu.
+# Token.Menu.Completions.Completion: 'bg:#44bbbb #000000',
+# Token.Menu.Completions.Completion.Current: 'bg:#008888 #ffffff',
+# Token.Menu.Completions.Meta: 'bg:#449999 #000000',
+# Token.Menu.Completions.Meta.Current: 'bg:#00aaaa #000000',
+# Token.Menu.Completions.ProgressBar: 'bg:#aaaaaa',
+# Token.Menu.Completions.ProgressButton: 'bg:#000000',
+# })
diff --git a/ptpython/utils.py b/ptpython/utils.py
new file mode 100644
index 0000000..28887d2
--- /dev/null
+++ b/ptpython/utils.py
@@ -0,0 +1,212 @@
+"""
+For internal use only.
+"""
+from __future__ import annotations
+
+import re
+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",
+ "document_is_multiline_python",
+ "unindent_code",
+]
+
+
+def has_unclosed_brackets(text: str) -> bool:
+ """
+ Starting at the end of the string. If we find an opening bracket
+ for which we didn't had a closing one yet, return True.
+ """
+ stack = []
+
+ # Ignore braces inside strings
+ text = re.sub(r"""('[^']*'|"[^"]*")""", "", text) # XXX: handle escaped quotes.!
+
+ for c in reversed(text):
+ if c in "])}":
+ stack.append(c)
+
+ elif c in "[({":
+ if stack:
+ if (
+ (c == "[" and stack[-1] == "]")
+ or (c == "{" and stack[-1] == "}")
+ or (c == "(" and stack[-1] == ")")
+ ):
+ stack.pop()
+ else:
+ # Opening bracket for which we didn't had a closing one.
+ return True
+
+ return False
+
+
+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'.
+
+ try:
+ return jedi.Interpreter(
+ document.text,
+ path="input-text",
+ namespaces=[locals, globals],
+ )
+ except ValueError:
+ # Invalid cursor position.
+ # ValueError('`column` parameter is not in a valid range.')
+ return None
+ except AttributeError:
+ # Workaround for #65: https://github.com/jonathanslenders/python-prompt-toolkit/issues/65
+ # See also: https://github.com/davidhalter/jedi/issues/508
+ return None
+ except IndexError:
+ # Workaround Jedi issue #514: for https://github.com/davidhalter/jedi/issues/514
+ return None
+ except KeyError:
+ # 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
+ return None
+
+
+_multiline_string_delims = re.compile("""[']{3}|["]{3}""")
+
+
+def document_is_multiline_python(document: Document) -> bool:
+ """
+ Determine whether this is a multiline Python document.
+ """
+
+ def ends_in_multiline_string() -> bool:
+ """
+ ``True`` if we're inside a multiline string at the end of the text.
+ """
+ delims = _multiline_string_delims.findall(document.text)
+ opening = None
+ for delim in delims:
+ if opening is None:
+ opening = delim
+ elif delim == opening:
+ opening = None
+ return bool(opening)
+
+ if "\n" in document.text or ends_in_multiline_string():
+ return True
+
+ def line_ends_with_colon() -> bool:
+ return document.current_line.rstrip()[-1:] == ":"
+
+ # If we just typed a colon, or still have open brackets, always insert a real newline.
+ if (
+ line_ends_with_colon()
+ or (
+ document.is_cursor_at_the_end
+ and has_unclosed_brackets(document.text_before_cursor)
+ )
+ or document.text.startswith("@")
+ ):
+ return True
+
+ # If the character before the cursor is a backslash (line continuation
+ # char), insert a new line.
+ elif document.text_before_cursor[-1:] == "\\":
+ return True
+
+ return False
+
+
+_T = TypeVar("_T", bound=Callable[[MouseEvent], None])
+
+
+def if_mousedown(handler: _T) -> _T:
+ """
+ Decorator for mouse handlers.
+ Only handle event when the user pressed mouse down.
+
+ (When applied to a token list. Scroll events will bubble up and are handled
+ by the Window.)
+ """
+
+ def handle_if_mouse_down(mouse_event: MouseEvent) -> NotImplementedOrNone:
+ if mouse_event.event_type == MouseEventType.MOUSE_DOWN:
+ return handler(mouse_event)
+ else:
+ return NotImplemented
+
+ return cast(_T, handle_if_mouse_down)
+
+
+_T_type = TypeVar("_T_type", bound=type)
+
+
+def ptrepr_to_repr(cls: _T_type) -> _T_type:
+ """
+ Generate a normal `__repr__` method for classes that have a `__pt_repr__`.
+ """
+ if not hasattr(cls, "__pt_repr__"):
+ raise TypeError(
+ "@ptrepr_to_repr can only be applied to classes that have a `__pt_repr__` method."
+ )
+
+ 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
+ return cls
+
+
+def unindent_code(text: str) -> str:
+ """
+ Remove common leading whitespace when all lines are indented.
+ """
+ lines = text.splitlines(keepends=True)
+
+ # Look for common prefix.
+ common_prefix = _common_whitespace_prefix(lines)
+
+ # Remove indentation.
+ lines = [line[len(common_prefix) :] for line in lines]
+
+ return "".join(lines)
+
+
+def _common_whitespace_prefix(strings: Iterable[str]) -> str:
+ """
+ Return common prefix for a list of lines.
+ This will ignore lines that contain whitespace only.
+ """
+ # Ignore empty lines and lines that have whitespace only.
+ strings = [s for s in strings if not s.isspace() and not len(s) == 0]
+
+ if not strings:
+ return ""
+
+ else:
+ s1 = min(strings)
+ s2 = max(strings)
+
+ for i, c in enumerate(s1):
+ if c != s2[i] or c not in " \t":
+ return s1[:i]
+
+ return s1
diff --git a/ptpython/validator.py b/ptpython/validator.py
new file mode 100644
index 0000000..91b9c28
--- /dev/null
+++ b/ptpython/validator.py
@@ -0,0 +1,62 @@
+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
+
+__all__ = ["PythonValidator"]
+
+
+class PythonValidator(Validator):
+ """
+ Validation of Python input.
+
+ :param get_compiler_flags: Callable that returns the currently
+ active compiler flags.
+ """
+
+ def __init__(self, get_compiler_flags: Callable[[], int] | None = None) -> None:
+ self.get_compiler_flags = get_compiler_flags
+
+ def validate(self, document: Document) -> None:
+ """
+ Check input for Python syntax errors.
+ """
+ text = unindent_code(document.text)
+
+ # When the input starts with Ctrl-Z, always accept. This means EOF in a
+ # Python REPL.
+ if text.startswith("\x1a"):
+ return
+
+ # When the input starts with an exclamation mark. Accept as shell
+ # command.
+ if text.lstrip().startswith("!"):
+ return
+
+ try:
+ if self.get_compiler_flags:
+ flags = self.get_compiler_flags()
+ else:
+ flags = 0
+
+ compile(text, "<input>", "exec", flags=flags, dont_inherit=True)
+ except SyntaxError as e:
+ # Note, the 'or 1' for offset is required because Python 2.7
+ # gives `None` as offset in case of '4=4' as input. (Looks like
+ # fixed in Python 3.)
+ # TODO: This is not correct if indentation was removed.
+ index = document.translate_row_col_to_index(
+ (e.lineno or 1) - 1, (e.offset or 1) - 1
+ )
+ raise ValidationError(index, f"Syntax Error: {e}")
+ except TypeError as e:
+ # e.g. "compile() expected string without null bytes"
+ raise ValidationError(0, str(e))
+ except ValueError as e:
+ # In Python 2, compiling "\x9" (an invalid escape sequence) raises
+ # ValueError instead of SyntaxError.
+ raise ValidationError(0, "Syntax Error: %s" % e)
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..5421c45
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,35 @@
+[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
new file mode 100644
index 0000000..80dfec6
--- /dev/null
+++ b/setup.cfg
@@ -0,0 +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
new file mode 100644
index 0000000..a54da35
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,57 @@
+#!/usr/bin/env python
+import os
+import sys
+
+from setuptools import find_packages, setup
+
+with open(os.path.join(os.path.dirname(__file__), "README.rst")) as f:
+ long_description = f.read()
+
+
+setup(
+ name="ptpython",
+ author="Jonathan Slenders",
+ version="3.0.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.34, because of `OneStyleAndTextTuple` import.
+ "prompt_toolkit>=3.0.34,<3.1.0",
+ "pygments",
+ ],
+ python_requires=">=3.7",
+ classifiers=[
+ "License :: OSI Approved :: BSD License",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3.7",
+ "Programming Language :: Python :: 3.7",
+ "Programming Language :: Python :: 3.8",
+ "Programming Language :: Python :: 3 :: Only",
+ "Programming Language :: Python",
+ ],
+ entry_points={
+ "console_scripts": [
+ "ptpython = ptpython.entry_points.run_ptpython:run",
+ "ptipython = ptpython.entry_points.run_ptipython:run",
+ "ptpython%s = ptpython.entry_points.run_ptpython:run" % sys.version_info[0],
+ "ptpython{}.{} = ptpython.entry_points.run_ptpython:run".format(
+ *sys.version_info[:2]
+ ),
+ "ptipython%s = ptpython.entry_points.run_ptipython:run"
+ % sys.version_info[0],
+ "ptipython{}.{} = ptpython.entry_points.run_ptipython:run".format(
+ *sys.version_info[:2]
+ ),
+ ]
+ },
+ extras_require={
+ "ptipython": ["ipython"], # For ptipython, we need to have IPython
+ "all": ["black"], # Black not always possible on PyPy
+ },
+)
diff --git a/tests/run_tests.py b/tests/run_tests.py
new file mode 100755
index 0000000..0de3743
--- /dev/null
+++ b/tests/run_tests.py
@@ -0,0 +1,24 @@
+#!/usr/bin/env python
+from __future__ import annotations
+
+import unittest
+
+import ptpython.completer
+import ptpython.eventloop
+import ptpython.filters
+import ptpython.history_browser
+import ptpython.key_bindings
+import ptpython.layout
+import ptpython.python_input
+import ptpython.repl
+import ptpython.style
+import ptpython.utils
+import ptpython.validator
+
+# For now there are no tests here.
+# However this is sufficient for Travis to do at least a syntax check.
+# That way we are at least sure to restrict to the Python 2.6 syntax.
+
+
+if __name__ == "__main__":
+ unittest.main()