diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 00000000..d53bfcc1 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,41 @@ +name: test + +on: + push: # any branch + pull_request: + branches: [master] + +jobs: + test-ubuntu: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] + + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Type Checking + run: | + uvx --with . mypy src/ptpython/ + uvx --with . mypy examples/ + - name: Code formatting + if: ${{ matrix.python-version == '3.13' }} + run: | + uvx ruff check . + uvx ruff format --check . + - name: Typos + if: ${{ matrix.python-version == '3.13' }} + run: | + uvx typos . + - name: Unit test + run: | + uvx --with . pytest tests/ + - name: Validate README.md + if: ${{ matrix.python-version == '3.13' }} + # Ensure that the README renders correctly (required for uploading to PyPI). + run: | + uv pip install readme_renderer + python -m readme_renderer README.rst > /dev/null diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 79a93e91..00000000 --- a/.travis.yml +++ /dev/null @@ -1,21 +0,0 @@ -sudo: false -language: python - -matrix: - include: - - python: 3.6 - - python: 3.5 - - python: 3.4 - - python: 3.3 - - python: 2.7 - - python: 2.6 - - python: pypy - - python: pypy3 - -install: - - travis_retry pip install . pytest - - pip list - -script: - - echo "$TRAVIS_PYTHON_VERSION" - - ./tests/run_tests.py diff --git a/CHANGELOG b/CHANGELOG index d8fcd0aa..7706260d 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,377 @@ CHANGELOG ========= +3.0.30: 2025-04-15 +------------------ + +New features: +- Show exception cause/context when printing chained exceptions. +- Reworked project layout and use pyproject.toml instead of setup.py. + +Breaking changes: +- Drop Python 3.7 support. + + +3.0.29: 2024-07-22 +------------------ + +Fixes: +- Further improve performance of dictionary completions. + + +3.0.28: 2024-07-22 +------------------ + +New features: +- Custom 'exit' function to return from REPL that + * doesn't terminate `sys.stdin` when `exit` is called (important for + `embed()`). + * doesn't require to be called with parentheses. + +Fixes: +- Clean up signatures on control-c. + + +3.0.27: 2024-05-27 +------------------ + +- Limit number of completions to 5k (for performance). +- Several typing fixes. + + +3.0.26: 2024-02-06 +------------------ + +Fixes: +- Handle `GeneratorExit` exception when leaving the paginator. + + +3.0.25: 2023-12-14 +------------------ + +Fixes: +- Fix handling of 'config file does not exist' when embedding ptpython. + + +3.0.24: 2023-12-13 +------------------ + +Fixes: +- Don't show "Impossible to read config file" warnings when no config file was + passed to `run_config()`. +- IPython integration fixes: + * Fix top-level await in IPython. + * Fix IPython `DeprecationWarning`. +- Output printing fixes: + * Paginate exceptions if pagination is enabled. + * Handle big outputs without running out of memory. +- Asyncio REPL improvements: + * From now on, passing `--asyncio` is required to activate the asyncio-REPL. + This will ensure that an event loop is created at the start in which we can + run top-level await statements. + * Use `get_running_loop()` instead of `get_event_loop()`. + * Better handling of `SystemExit` and control-c in the async REPL. + + +3.0.23: 2023-02-22 +------------------ + +Fixes: +- Don't print exception messages twice for unhandled exceptions. +- Added cursor shape support. + +Breaking changes: +- Drop Python 3.6 support. + + +3.0.22: 2022-12-06 +------------------ + +New features: +- Improve rendering performance when there are many completions. + + +3.0.21: 2022-11-25 +------------------ + +New features: +- Make ptipython respect more config changes. + (See: https://github.com/prompt-toolkit/ptpython/pull/110 ) +- Improved performance of `DictionaryCompleter` for slow mappings. + +Fixes: +- Call `super()` in `PythonInputFilter`. This will prevent potentially breakage + with an upcoming prompt_toolkit change. + (See: https://github.com/prompt-toolkit/python-prompt-toolkit/pull/1690 ) +- Improved type annotations. +- Added `py.typed` to the `package_data`. + + +3.0.20: 2021-09-14 +------------------ + +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 ----------------- diff --git a/LICENSE b/LICENSE index 910b80a7..89a51144 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2015, Jonathan Slenders +Copyright (c) 2015-2023, Jonathan Slenders All rights reserved. Redistribution and use in source and binary forms, with or without modification, diff --git a/README.rst b/README.rst index b953b02d..06c1e02b 100644 --- a/README.rst +++ b/README.rst @@ -1,6 +1,8 @@ ptpython ======== +|Build Status| |PyPI| |License| + *A better Python REPL* :: @@ -9,12 +11,13 @@ ptpython .. image :: https://github.com/jonathanslenders/ptpython/raw/master/docs/images/example1.png -|Build Status| - Ptpython is an advanced Python REPL. It should work on all -Python versions from 2.6 up to 3.7 and work cross platform (Linux, +Python versions from 2.6 up to 3.11 and work cross platform (Linux, BSD, OS X and Windows). +Note: this version of ptpython requires at least Python 3.6. Install ptpython +2.0.5 for older Python versions. + Installation ************ @@ -47,6 +50,59 @@ Features [2] If the terminal supports it (most terminals do), this allows pasting without going into paste mode. It will keep the indentation. +Command Line Options +******************** + +The help menu shows basic command-line options. + +:: + + $ ptpython --help + usage: ptpython [-h] [--vi] [-i] [--light-bg] [--dark-bg] [--config-file CONFIG_FILE] + [--history-file HISTORY_FILE] [-V] + [args ...] + + ptpython: Interactive Python shell. + + positional arguments: + args Script and arguments + + optional arguments: + -h, --help show this help message and exit + --vi Enable Vi key bindings + -i, --interactive Start interactive shell after executing this file. + --asyncio Run an asyncio event loop to support top-level "await". + --light-bg Run on a light background (use dark colors for text). + --dark-bg Run on a dark background (use light colors for text). + --config-file CONFIG_FILE + Location of configuration file. + --history-file HISTORY_FILE + Location of history file. + -V, --version show program's version number and exit + + environment variables: + PTPYTHON_CONFIG_HOME: a configuration directory to use + PYTHONSTARTUP: file executed on interactive startup (no default) + + +__pt_repr__: A nicer repr with colors +************************************* + +When classes implement a ``__pt_repr__`` method, this will be used instead of +``__repr__`` for printing. Any `prompt_toolkit "formatted text" +`_ +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('Hello world!') More screenshots **************** @@ -88,6 +144,8 @@ like this: else: sys.exit(embed(globals(), locals())) +Note config file support currently only works when invoking `ptpython` directly. +That it, the config file will be ignored when embedding ptpython in an application. Multiline editing ***************** @@ -114,6 +172,20 @@ error. .. image :: https://github.com/jonathanslenders/ptpython/raw/master/docs/images/validation.png +Asyncio REPL and top level await +******************************** + +In order to get top-level ``await`` support, start ptpython as follows: + +.. code:: + + ptpython --asyncio + +This will spawn an asyncio event loop and embed the async REPL in the event +loop. After this, top-level await will work and statements like ``await +asyncio.sleep(10)`` will execute. + + Additional features ******************* @@ -121,18 +193,26 @@ 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 on ``V`` (major V) in Vi +Selecting text: Press ``Control+Space`` in Emacs mode or ``V`` (major V) in Vi navigation mode. Configuration ************* -It is possible to create a ``~/.ptpython/config.py`` file to customize the configuration. +It is possible to create a ``config.py`` file to customize configuration. +ptpython will look in an appropriate platform-specific directory via `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 `_ +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 *************** @@ -144,6 +224,13 @@ 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 ************** @@ -168,6 +255,22 @@ Windows. Some things might not work, but it is usable: .. image :: https://github.com/jonathanslenders/ptpython/raw/master/docs/images/windows.png +Windows terminal integration +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If you are using the `Windows Terminal `_ and want to +integrate ``ptpython`` as a profile, go to *Settings -> Open JSON file* and add the +following profile under *profiles.list*: + +.. code-block:: JSON + + { + "commandline": "%SystemRoot%\\System32\\cmd.exe /k ptpython", + "guid": "{f91d49a3-741b-409c-8a15-c4360649121f}", + "hidden": false, + "icon": "https://upload.wikimedia.org/wikipedia/commons/e/e6/Python_Windows_interpreter_icon_2006%E2%80%932016_Tiny.png", + "name": "ptpython@cmd" + } FAQ *** @@ -178,7 +281,7 @@ FAQ **Q**: The ``Meta``-key doesn't work. -**A**: For some terminals you have to enable the Alt-key to act as meta key, but you +**A**: For some terminals you have to enable the Alt-key to act as meta key, but you can also type ``Escape`` before any key instead. @@ -198,13 +301,15 @@ Special thanks to - `Pygments `_: Syntax highlighter. - `Jedi `_: Autocompletion library. -- `Docopt `_: Command-line interface description language. - `wcwidth `_: Determine columns needed for a wide characters. - `prompt_toolkit `_ for the interface. -.. |Build Status| image:: https://api.travis-ci.org/jonathanslenders/ptpython.svg?branch=master - :target: https://travis-ci.org/jonathanslenders/ptpython# +.. |Build Status| image:: https://github.com/prompt-toolkit/ptpython/actions/workflows/test.yaml/badge.svg + :target: https://github.com/prompt-toolkit/ptpython/actions/workflows/test.yaml + +.. |License| image:: https://img.shields.io/github/license/prompt-toolkit/ptpython.svg + :target: https://github.com/prompt-toolkit/ptpython/blob/master/LICENSE -.. |PyPI| image:: https://pypip.in/version/prompt-toolkit/badge.svg - :target: https://pypi.python.org/pypi/prompt-toolkit/ +.. |PyPI| image:: https://img.shields.io/pypi/v/ptpython.svg + :target: https://pypi.org/project/ptpython/ :alt: Latest Version diff --git a/docs/concurrency-challenges.rst b/docs/concurrency-challenges.rst new file mode 100644 index 00000000..0ff9c6c3 --- /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/examples/asyncio-python-embed.py b/examples/asyncio-python-embed.py index fef19b7f..cb909731 100755 --- a/examples/asyncio-python-embed.py +++ b/examples/asyncio-python-embed.py @@ -11,47 +11,44 @@ to stdout, it won't break the input line, but instead writes nicely above the prompt. """ -from __future__ import unicode_literals -from ptpython.repl import embed import asyncio +from ptpython.repl import embed + loop = asyncio.get_event_loop() counter = [0] -@asyncio.coroutine -def print_counter(): +async def print_counter() -> None: """ Coroutine that prints counters and saves it in a global variable. """ while True: - print('Counter: %i' % counter[0]) + print(f"Counter: {counter[0]}") counter[0] += 1 - yield from asyncio.sleep(3) + await asyncio.sleep(3) -@asyncio.coroutine -def interactive_shell(): +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.') + print( + 'You should be able to read and update the "counter[0]" variable from this shell.' + ) try: - yield from embed(globals=globals(), return_asyncio_coroutine=True, patch_stdout=True) + 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() -def main(): - asyncio.ensure_future(print_counter()) - asyncio.ensure_future(interactive_shell()) - - loop.run_forever() - loop.close() +async def main() -> None: + asyncio.create_task(print_counter()) + await interactive_shell() -if __name__ == '__main__': - main() +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/asyncio-ssh-python-embed.py b/examples/asyncio-ssh-python-embed.py index cbd07003..bf79df78 100755 --- a/examples/asyncio-ssh-python-embed.py +++ b/examples/asyncio-ssh-python-embed.py @@ -5,10 +5,12 @@ Run this example and then SSH to localhost, port 8222. """ + import asyncio -import asyncssh import logging +import asyncssh + from ptpython.contrib.asyncssh_repl import ReplSSHServerSession logging.basicConfig() @@ -19,6 +21,7 @@ class MySSHServer(asyncssh.SSHServer): """ Server without authentication, running `ReplSSHServerSession`. """ + def __init__(self, get_namespace): self.get_namespace = get_namespace @@ -30,29 +33,25 @@ def session_requested(self): return ReplSSHServerSession(self.get_namespace) -def main(port=8222): +async def main(port: int = 8222) -> None: """ Example that starts the REPL through an SSH server. """ - loop = asyncio.get_event_loop() - # Namespace exposed in the REPL. - environ = {'hello': 'world'} + environ = {"hello": "world"} # Start SSH server. - def create_server(): + def create_server() -> MySSHServer: return MySSHServer(lambda: environ) - print('Listening on :%i' % port) - print('To connect, do "ssh localhost -p %i"' % port) - - loop.run_until_complete( - asyncssh.create_server(create_server, '', port, - server_host_keys=['/etc/ssh/ssh_host_dsa_key'])) + print(f"Listening on: {port}") + print(f'To connect, do "ssh localhost -p {port}"') - # Run eventloop. - loop.run_forever() + await asyncssh.create_server( + create_server, "", port, server_host_keys=["/etc/ssh/ssh_host_dsa_key"] + ) + await asyncio.Future() # Wait forever. -if __name__ == '__main__': - main() +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/ptpython_config/config.py b/examples/ptpython_config/config.py index a834112c..bfd3914e 100644 --- a/examples/ptpython_config/config.py +++ b/examples/ptpython_config/config.py @@ -1,19 +1,19 @@ """ Configuration example for ``ptpython``. -Copy this file to ~/.ptpython/config.py +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 __future__ import unicode_literals + from prompt_toolkit.filters import ViInsertMode from prompt_toolkit.key_binding.key_processor import KeyPress from prompt_toolkit.keys import Keys -from pygments.token import Token +from prompt_toolkit.styles import Style from ptpython.layout import CompletionVisualisation -__all__ = ( - 'configure', -) +__all__ = ["configure"] def configure(repl): @@ -48,7 +48,10 @@ def configure(repl): # When the sidebar is visible, also show the help text. repl.show_sidebar_help = True - # Highlight matching parethesis. + # 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.) @@ -61,14 +64,21 @@ def configure(repl): # 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' + repl.prompt_style = "classic" # 'classic' or 'ipython' # Don't insert a blank line after the output. repl.insert_blank_line_after_output = False @@ -85,7 +95,7 @@ def configure(repl): # based on the history.) repl.enable_auto_suggest = False - # Enable open-in-editor. Pressing C-X C-E in emacs mode or 'v' in + # 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 @@ -101,60 +111,75 @@ def configure(repl): repl.enable_input_validation = True # Use this colorscheme for the code. - repl.use_code_colorscheme('pastie') + # 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. + # 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', _custom_ui_colorscheme) - repl.use_ui_colorscheme('my-colorscheme') + 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(Keys.ControlB) + @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') + " 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(Keys.ControlE, Keys.ControlE) + @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()) + @repl.add_key_binding("j", "j", filter=ViInsertMode()) def _(event): " Map 'jj' to Escape. " - event.cli.key_processor.feed(KeyPress(Keys.Escape)) + event.cli.key_processor.feed(KeyPress(Keys("escape"))) """ # Custom key binding for some simple autocorrection while typing. """ corrections = { - 'impotr': 'import', - 'pritn': 'print', + "impotr": "import", + "pritn": "print", } - @repl.add_key_binding(' ') + @repl.add_key_binding(" ") def _(event): - ' When a space is pressed. Check & correct word before cursor. ' + " When a space is pressed. Check & correct word before cursor. " b = event.cli.current_buffer w = b.document.get_word_before_cursor() @@ -163,7 +188,13 @@ def _(event): b.delete_before_cursor(count=len(w)) b.insert_text(corrections[w]) - b.insert_text(' ') + 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." """ @@ -171,8 +202,7 @@ def _(event): # `ptpython/style.py` for all possible tokens. _custom_ui_colorscheme = { # Blue prompt. - Token.Layout.Prompt: 'bg:#eeeeff #000000 bold', - + "prompt": "bg:#eeeeff #000000 bold", # Make the status toolbar red. - Token.Toolbar.Status: 'bg:#ff0000 #000000', + "status-toolbar": "bg:#ff0000 #000000", } diff --git a/examples/python-embed-with-custom-prompt.py b/examples/python-embed-with-custom-prompt.py index 28eca860..5e8c7079 100755 --- a/examples/python-embed-with-custom-prompt.py +++ b/examples/python-embed-with-custom-prompt.py @@ -2,55 +2,38 @@ """ Example of embedding a Python REPL, and setting a custom prompt. """ -from __future__ import unicode_literals -from ptpython.repl import embed -from ptpython.prompt_style import PromptStyle -from pygments.token import Token +from prompt_toolkit.formatted_text import HTML, AnyFormattedText +from ptpython.prompt_style import PromptStyle +from ptpython.repl import embed -def configure(repl): - # There are several ways to override the prompt. - # 1. 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. +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_tokens(self, cli): - return [ - (Token.In, 'Input['), - (Token.In.Number, '%s' % repl.current_statement_index), - (Token.In, '] >>: '), - ] - - def in2_tokens(self, cli, width): - return [ - (Token.In, '...: '.rjust(width)), - ] - - def out_tokens(self, cli): - return [ - (Token.Out, 'Result['), - (Token.Out.Number, '%s' % repl.current_statement_index), - (Token.Out, ']: '), - ] - - repl.all_prompt_styles['custom'] = CustomPrompt() - repl.prompt_style = 'custom' + def in_prompt(self) -> AnyFormattedText: + return HTML("Input[%s]: ") % ( + repl.current_statement_index, + ) - # 2. Assign a new callable to `get_input_prompt_tokens`. This will always take effect. - ## repl.get_input_prompt_tokens = lambda cli: [(Token.In, '[hello] >>> ')] + def in2_prompt(self, width: int) -> AnyFormattedText: + return "...: ".rjust(width) - # 3. Also replace `get_input_prompt_tokens`, but still call the original. This inserts - # a prefix. + def out_prompt(self) -> AnyFormattedText: + return HTML("Result[%s]: ") % ( + repl.current_statement_index, + ) - ## original = repl.get_input_prompt_tokens - ## repl.get_input_prompt_tokens = lambda cli: [(Token.In, '[prefix]')] + original(cli) + repl.all_prompt_styles["custom"] = CustomPrompt() + repl.prompt_style = "custom" -def main(): +def main() -> None: embed(globals(), locals(), configure=configure) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/examples/python-embed.py b/examples/python-embed.py index 72c1c101..a7481011 100755 --- a/examples/python-embed.py +++ b/examples/python-embed.py @@ -1,14 +1,12 @@ #!/usr/bin/env python -""" -""" -from __future__ import unicode_literals +""" """ from ptpython.repl import embed -def main(): +def main() -> None: embed(globals(), locals(), vi_mode=False) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/examples/python-input.py b/examples/python-input.py index bcfd6fca..d586d0f5 100755 --- a/examples/python-input.py +++ b/examples/python-input.py @@ -1,7 +1,5 @@ #!/usr/bin/env python -""" -""" -from __future__ import unicode_literals +""" """ from ptpython.python_input import PythonInput @@ -10,8 +8,8 @@ def main(): prompt = PythonInput() text = prompt.app.run() - print('You said: ' + text) + print("You said: " + text) -if __name__ == '__main__': +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 00000000..2b293e6f --- /dev/null +++ b/examples/ssh-and-telnet-embed.py @@ -0,0 +1,54 @@ +#!/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 +""" + +from __future__ import annotations + +import asyncio +import pathlib + +import asyncssh +from prompt_toolkit import print_formatted_text +from prompt_toolkit.contrib.ssh.server import ( + PromptToolkitSSHServer, + PromptToolkitSSHSession, +) +from prompt_toolkit.contrib.telnet.server import TelnetConnection, 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 | TelnetConnection) -> 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 00000000..bfe14109 --- /dev/null +++ b/examples/test-cases/ptpython-in-other-thread.py @@ -0,0 +1,25 @@ +#!/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/ptpython/completer.py b/ptpython/completer.py deleted file mode 100644 index 7a63912a..00000000 --- a/ptpython/completer.py +++ /dev/null @@ -1,166 +0,0 @@ -from __future__ import unicode_literals - -from prompt_toolkit.completion import Completer, Completion, PathCompleter -from prompt_toolkit.contrib.regular_languages.compiler import compile as compile_grammar -from prompt_toolkit.contrib.regular_languages.completion import GrammarCompleter - -from ptpython.utils import get_jedi_script_from_document - -import re - -__all__ = ( - 'PythonCompleter', -) - - -class PythonCompleter(Completer): - """ - Completer for Python code. - """ - def __init__(self, get_globals, get_locals): - super(PythonCompleter, self).__init__() - - self.get_globals = get_globals - self.get_locals = get_locals - - self._path_completer_cache = None - self._path_completer_grammar_cache = None - - @property - def _path_completer(self): - 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): - """ - 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): - def unwrapper(text): - return re.sub(r'\\(.)', r'\1', text) - - def single_quoted_wrapper(text): - return text.replace('\\', '\\\\').replace("'", "\\'") - - def double_quoted_wrapper(text): - 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([^\n'\\]|\\.)*) | # Inside a single quoted string. - " (?P([^\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): - char_before_cursor = document.char_before_cursor - return document.text and ( - char_before_cursor.isalnum() or char_before_cursor in '/.~') - - def _complete_python_while_typing(self, document): - char_before_cursor = document.char_before_cursor - return document.text and ( - char_before_cursor.isalnum() or char_before_cursor in '_.') - - def get_completions(self, document, complete_event): - """ - Get Python completions. - """ - # Do Path completions - if complete_event.completion_requested or self._complete_path_while_typing(document): - for c in self._path_completer.get_completions(document, complete_event): - yield c - - # If we are inside a string, Don't do Jedi completion. - if self._path_completer_grammar.match(document.text_before_cursor): - return - - # Do Jedi Python completions. - if complete_event.completion_requested or self._complete_python_while_typing(document): - script = get_jedi_script_from_document(document, self.get_locals(), self.get_globals()) - - if script: - try: - completions = script.completions() - 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 IOError: - # 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: - # Supress all other Jedi exceptions. - pass - else: - for c in completions: - yield Completion(c.name_with_symbols, len(c.complete) - len(c.name_with_symbols), - display=c.name_with_symbols) diff --git a/ptpython/entry_points/run_ptipython.py b/ptpython/entry_points/run_ptipython.py deleted file mode 100644 index a563f52e..00000000 --- a/ptpython/entry_points/run_ptipython.py +++ /dev/null @@ -1,99 +0,0 @@ -#!/usr/bin/env python -""" -ptipython: IPython interactive shell with the `prompt_toolkit` front-end. -Usage: - ptpython [ --vi ] - [ --config-dir= ] [ --interactive= ] - [--] [ ... ] - ptpython -h | --help - -Options: - --vi : Use Vi keybindings instead of Emacs bindings. - --config-dir= : Pass config directory. By default '~/.ptpython/'. - -i, --interactive= : Start interactive shell after executing this file. -""" -from __future__ import absolute_import, unicode_literals - -import docopt -import os -import six -import sys - - -def run(user_ns=None): - a = docopt.docopt(__doc__) - - vi_mode = bool(a['--vi']) - config_dir = os.path.expanduser(a['--config-dir'] or '~/.ptpython/') - - # Create config directory. - if not os.path.isdir(config_dir): - os.mkdir(config_dir) - - # 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 run_config, enable_deprecation_warnings - - # 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[''] and not a['--interactive']: - sys.argv = a[''] - path = a[''][0] - with open(path, 'rb') as f: - code = compile(f.read(), path, 'exec') - six.exec_(code) - 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['--interactive']) - sys.argv = [a['--interactive']] + a[''] - - # 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') - six.exec_(code, user_ns, user_ns) - else: - print('File not found: {}\n\n'.format(path)) - sys.exit(1) - - # Apply config file - def configure(repl): - path = os.path.join(config_dir, 'config.py') - if os.path.exists(path): - run_config(repl, path) - - # Run interactive shell. - embed(vi_mode=vi_mode, - history_filename=os.path.join(config_dir, 'history'), - 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 deleted file mode 100644 index 356d6bd3..00000000 --- a/ptpython/entry_points/run_ptpython.py +++ /dev/null @@ -1,80 +0,0 @@ -#!/usr/bin/env python -""" -ptpython: Interactive Python shell. -Usage: - ptpython [ --vi ] - [ --config-dir= ] [ --interactive= ] - [--] [ ... ] - ptpython -h | --help - -Options: - --vi : Use Vi keybindings instead of Emacs bindings. - --config-dir= : Pass config directory. By default '~/.ptpython/'. - -i, --interactive= : Start interactive shell after executing this file. - -Other environment variables: -PYTHONSTARTUP: file executed on interactive startup (no default) -""" -from __future__ import absolute_import, unicode_literals - -import docopt -import os -import six -import sys - -from ptpython.repl import embed, enable_deprecation_warnings, run_config - - -def run(): - a = docopt.docopt(__doc__) - - vi_mode = bool(a['--vi']) - config_dir = os.path.expanduser(a['--config-dir'] or '~/.ptpython/') - - # Create config directory. - if not os.path.isdir(config_dir): - os.mkdir(config_dir) - - # Startup path - startup_paths = [] - if 'PYTHONSTARTUP' in os.environ: - startup_paths.append(os.environ['PYTHONSTARTUP']) - - # --interactive - if a['--interactive']: - startup_paths.append(a['--interactive']) - sys.argv = [a['--interactive']] + a[''] - - # 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[''] and not a['--interactive']: - sys.argv = a[''] - path = a[''][0] - with open(path, 'rb') as f: - code = compile(f.read(), path, 'exec') - six.exec_(code) - - # Run interactive shell. - else: - enable_deprecation_warnings() - - # Apply config file - def configure(repl): - path = os.path.join(config_dir, 'config.py') - if os.path.exists(path): - run_config(repl, path) - - import __main__ - embed(vi_mode=vi_mode, - history_filename=os.path.join(config_dir, 'history'), - configure=configure, - locals=__main__.__dict__, - globals=__main__.__dict__, - startup_paths=startup_paths, - title='Python REPL (ptpython)') - -if __name__ == '__main__': - run() diff --git a/ptpython/ipython.py b/ptpython/ipython.py deleted file mode 100644 index be4bd178..00000000 --- a/ptpython/ipython.py +++ /dev/null @@ -1,259 +0,0 @@ -""" - -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 unicode_literals, print_function - -from prompt_toolkit.completion import Completion, Completer -from prompt_toolkit.completion import 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 PygmentsTokens -from prompt_toolkit.lexers import PygmentsLexer, SimpleLexer -from prompt_toolkit.styles import Style - -from .python_input import PythonInput, PythonValidator, PythonCompleter -from .style import default_ui_style - -from IPython.terminal.embed import InteractiveShellEmbed as _InteractiveShellEmbed -from IPython.terminal.ipapp import load_default_config -from IPython import utils as ipy_utils -from IPython.core.inputsplitter import IPythonInputSplitter - -from pygments.lexers import PythonLexer, BashLexer -from ptpython.prompt_style import PromptStyle - -__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): - return PygmentsTokens(self.prompts.in_prompt_tokens()) - - def in2_prompt(self, width): - return PygmentsTokens(self.prompts.continuation_prompt_tokens()) - - def out_prompt(self): - return [] - - -class IPythonValidator(PythonValidator): - def __init__(self, *args, **kwargs): - super(IPythonValidator, self).__init__(*args, **kwargs) - self.isp = IPythonInputSplitter() - - def validate(self, document): - document = Document(text=self.isp.transform_cell(document.text)) - super(IPythonValidator, self).validate(document) - - -def create_ipython_grammar(): - """ - Return compiled IPython grammar. - """ - return compile(r""" - \s* - ( - (?P%)( - (?Ppycat|run|loadpy|load) \s+ (?P[^\s]+) | - (?Pcat) \s+ (?P[^\s]+) | - (?Ppushd|cd|ls) \s+ (?P[^\s]+) | - (?Ppdb) \s+ (?P[^\s]+) | - (?Pautocall) \s+ (?P[^\s]+) | - (?Ptime|timeit|prun) \s+ (?P.+) | - (?Ppsource|pfile|pinfo|pinfo2) \s+ (?P.+) | - (?Psystem) \s+ (?P.+) | - (?Punalias) \s+ (?P.+) | - (?P[^\s]+) .* | - ) .* | - !(?P.+) | - (?![%!]) (?P.+) - ) - \s* - """) - - -def create_completer(get_globals, get_locals, magics_manager, alias_manager): - g = create_ipython_grammar() - - return GrammarCompleter(g, { - 'python': PythonCompleter(get_globals, get_locals), - '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, complete_event): - 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, complete_event): - 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) - kw['_lexer'] = create_lexer() - kw['_validator'] = IPythonValidator( - get_compiler_flags=self.get_compiler_flags) - - super(IPythonInput, self).__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') - - -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(InteractiveShellEmbed, self).__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): - 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: - ipy_utils.warn.warn( - "Error in loading extension: %s" % ext + - "\nCheck your config files in %s" % ipy_utils.path.get_ipython_dir()) - shell.showtraceback() - - -def embed(**kwargs): - """ - Copied from `IPython/terminal/embed.py`, but using our `InteractiveShellEmbed` instead. - """ - config = kwargs.get('config') - header = kwargs.pop('header', u'') - 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']) - shell(header=header, stack_depth=2, compile_flags=compile_flags) diff --git a/ptpython/key_bindings.py b/ptpython/key_bindings.py deleted file mode 100644 index 001f59b9..00000000 --- a/ptpython/key_bindings.py +++ /dev/null @@ -1,289 +0,0 @@ -from __future__ import unicode_literals - -from prompt_toolkit.document import Document -from prompt_toolkit.enums import DEFAULT_BUFFER -from prompt_toolkit.filters import has_selection, has_focus, Condition, vi_insert_mode, emacs_insert_mode, emacs_mode -from prompt_toolkit.key_binding import KeyBindings -from prompt_toolkit.keys import Keys -from prompt_toolkit.application import get_app -from .utils import document_is_multiline_python - -__all__ = ( - 'load_python_bindings', - 'load_sidebar_bindings', - 'load_confirm_exit_bindings', -) - - -@Condition -def tab_should_insert_whitespace(): - """ - 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): - """ - Custom key bindings. - """ - bindings = KeyBindings() - - sidebar_visible = Condition(lambda: python_input.show_sidebar) - handle = bindings.add - - @handle('c-l') - def _(event): - """ - Clear whole screen and render again -- also when the sidebar is visible. - """ - event.app.renderer.clear() - - @handle('c-z') - def _(event): - """ - Suspend. - """ - if python_input.enable_system_bindings: - event.app.suspend_to_background() - - @handle('f2') - def _(event): - """ - 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): - """ - Select from the history. - """ - python_input.enter_history() - - @handle('f4') - def _(event): - """ - Toggle between Vi and Emacs mode. - """ - python_input.vi_mode = not python_input.vi_mode - - @handle('f6') - def _(event): - """ - 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): - """ - When tab should insert whitespace, do that instead of completion. - """ - event.app.current_buffer.insert_text(' ') - - @Condition - def is_multiline(): - 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): - """ - 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): - """ - Behaviour of the Enter key. - - Auto indent after newline/Enter. - (When not in Vi navigaton 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): - """ we consider the cursor at the end when there is no text after - the cursor, or only whitespace. """ - text = b.document.text_after_cursor - return text == '' or (text.isspace() and not '\n' in text) - - 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): - """ - Override Control-D exit, to ask for confirmation. - """ - if python_input.confirm_exit: - python_input.show_exit_confirmation = True - else: - event.app.exit(exception=EOFError) - - @handle('c-c', filter=has_focus(python_input.default_buffer)) - def _(event): - " Abort when Control-C has been pressed. " - event.app.exit(exception=KeyboardInterrupt, style='class:aborting') - - return bindings - - -def load_sidebar_bindings(python_input): - """ - 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): - " 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): - " 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): - " Select next value for current option. " - option = python_input.selected_option - option.activate_next() - - @handle('left', filter=sidebar_visible) - @handle('h', filter=sidebar_visible) - def _(event): - " Select previous value for current option. " - 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): - " Hide sidebar. " - python_input.show_sidebar = False - event.app.layout.focus_last() - - return bindings - - -def load_confirm_exit_bindings(python_input): - """ - 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): - """ - Really quit. - """ - event.app.exit(exception=EOFError, style='class:exiting') - - @handle(Keys.Any, filter=confirmation_visible) - def _(event): - """ - Cancel exit. - """ - python_input.show_exit_confirmation = False - - return bindings - - -def auto_newline(buffer): - 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 deleted file mode 100644 index 3cc230f0..00000000 --- a/ptpython/layout.py +++ /dev/null @@ -1,622 +0,0 @@ -""" -Creation of the `Layout` instance for the Python input/REPL. -""" -from __future__ import unicode_literals - -from prompt_toolkit.application import get_app -from prompt_toolkit.enums import DEFAULT_BUFFER, SEARCH_BUFFER -from prompt_toolkit.filters import is_done, has_completions, renderer_height_is_known, has_focus, Condition -from prompt_toolkit.formatted_text import fragment_list_width, to_formatted_text -from prompt_toolkit.key_binding.vi_state import InputMode -from prompt_toolkit.layout.containers import Window, HSplit, VSplit, FloatContainer, Float, ConditionalContainer, ScrollOffsets -from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl -from prompt_toolkit.layout.dimension import 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 ConditionalProcessor, AppendAutoSuggestion, HighlightIncrementalSearchProcessor, HighlightSelectionProcessor, HighlightMatchingBracketProcessor, Processor, Transformation -from prompt_toolkit.lexers import SimpleLexer -from prompt_toolkit.selection import SelectionType -from prompt_toolkit.widgets.toolbars import CompletionsToolbar, ArgToolbar, SearchToolbar, ValidationToolbar, SystemToolbar - -from .filters import HasSignature, ShowSidebar, ShowSignature, ShowDocstring -from .utils import if_mousedown - -from pygments.lexers import PythonLexer - -import platform -import sys - -__all__ = ( - 'PtPythonLayout', - 'CompletionVisualisation', -) - - -# DisplayMultipleCursors: Only for prompt_toolkit>=1.0.8 -try: - from prompt_toolkit.layout.processors import DisplayMultipleCursors -except ImportError: - class DisplayMultipleCursors(Processor): - " Dummy. " - def __init__(self, *a): - pass - - def apply_transformation(self, document, lineno, - source_to_display, tokens): - return Transformation(tokens) - - -class CompletionVisualisation: - " Visualisation method for the completions. " - NONE = 'none' - POP_UP = 'pop-up' - MULTI_COLUMN = 'multi-column' - TOOLBAR = 'toolbar' - - -def show_completions_toolbar(python_input): - return Condition(lambda: python_input.completion_visualisation == CompletionVisualisation.TOOLBAR) - - -def show_completions_menu(python_input): - return Condition(lambda: python_input.completion_visualisation == CompletionVisualisation.POP_UP) - - -def show_multi_column_completions_menu(python_input): - return Condition(lambda: python_input.completion_visualisation == CompletionVisualisation.MULTI_COLUMN) - - -def python_sidebar(python_input): - """ - Create the `Layout` for the sidebar with the configurable options. - """ - def get_text_fragments(): - tokens = [] - - def append_category(category): - tokens.extend([ - ('class:sidebar', ' '), - ('class:sidebar.title', ' %-36s' % category.title), - ('class:sidebar', '\n'), - ]) - - def append(index, label, status): - selected = index == python_input.selected_option_index - - @if_mousedown - def select_item(mouse_event): - python_input.selected_option_index = index - - @if_mousedown - def goto_next(mouse_event): - " 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, '%s' % option.get_current_value()) - i += 1 - - tokens.pop() # Remove last newline. - - return tokens - - class Control(FormattedTextControl): - def move_cursor_down(self): - python_input.selected_option_index += 1 - - def move_cursor_up(self): - 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): - """ - Create the `Layout` showing the navigation information for the sidebar. - """ - def get_text_fragments(): - tokens = [] - - # Show navigation info. - tokens.extend([ - ('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 tokens - - return Window( - FormattedTextControl(get_text_fragments), - style='class:sidebar', - width=Dimension.exact(43), - height=Dimension.exact(1)) - - -def python_sidebar_help(python_input): - """ - Create the `Layout` for the help text for the current item in the sidebar. - """ - token = 'class:sidebar.helptext' - - def get_current_description(): - """ - 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(): - return [(token, get_current_description())] - - return ConditionalContainer( - content=Window( - FormattedTextControl(get_help_text), - style=token, - height=Dimension(min=3)), - filter=ShowSidebar(python_input) & - Condition(lambda: python_input.show_sidebar_help) & ~is_done) - - -def signature_toolbar(python_input): - """ - Return the `Layout` for the signature. - """ - def get_text_fragments(): - result = [] - 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.full_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', '(')) - - try: - enumerated_params = enumerate(sig.params) - except AttributeError: - # Workaround for #136: https://github.com/jonathanslenders/ptpython/issues/136 - # AttributeError: 'Lambda' object has no attribute 'get_subscope_by_name' - return [] - - for i, p in enumerated_params: - # Workaround for #47: 'p' is None when we hit the '*' in the signature. - # and sig has no 'index' attribute. - # See: https://github.com/jonathanslenders/ptpython/issues/47 - # https://github.com/davidhalter/jedi/issues/598 - description = (p.description if p else '*') #or '*' - 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', str(description))) - else: - append((Signature, str(description))) - append((Signature + ',operator', ', ')) - - if sig.params: - # Pop last comma - result.pop() - - append((Signature + ',operator', ')')) - append((Signature, ' ')) - return result - - return ConditionalContainer( - content=Window( - FormattedTextControl(get_text_fragments), - height=Dimension.exact(1)), - filter= - # Show only when there is a signature - HasSignature(python_input) & - # And there are no completions to be shown. (would cover signature pop-up.) - ~(has_completions & (show_completions_menu(python_input) | - show_multi_column_completions_menu(python_input))) - # Signature needs to be shown. - & ShowSignature(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): - self.python_input = python_input - - def get_prompt_style(): - return python_input.all_prompt_styles[python_input.prompt_style] - - def get_prompt(): - return to_formatted_text(get_prompt_style().in_prompt()) - - def get_continuation(width, line_number, is_soft_wrap): - if python_input.show_line_numbers and not is_soft_wrap: - text = ('%i ' % (line_number + 1)).rjust(width) - return [('class:line-number', text)] - else: - return get_prompt_style().in2_prompt(width) - - super(PythonPromptMargin, self).__init__(get_prompt, get_continuation) - - -def status_bar(python_input): - """ - Create the `Layout` for the status bar. - """ - TB = 'class:status-toolbar' - - @if_mousedown - def toggle_paste_mode(mouse_event): - python_input.paste_mode = not python_input.paste_mode - - @if_mousedown - def enter_history(mouse_event): - python_input.enter_history() - - def get_text_fragments(): - python_buffer = python_input.default_buffer - - result = [] - 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:key', '[F3]', enter_history), - (TB, ' History ', enter_history), - (TB + ' class: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): - """ - 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): - 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 = [] - append = result.append - - 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', 'RECORD({})'.format(recording_register))) - append((token, ' - ')) - - if bool(app.current_buffer.selection_state): - 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 == '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): - """ - Create `Layout` for the information in the right-bottom corner. - (The right part of the status bar.) - """ - @if_mousedown - def toggle_sidebar(mouse_event): - " Click handler for the menu. " - python_input.show_sidebar = not python_input.show_sidebar - - version = sys.version_info - tokens = [ - ('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(): - # 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 exit_confirmation(python_input, style='class:exit-confirmation'): - """ - Create `Layout` for the exit message. - """ - def get_text_fragments(): - # 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), style=style), # , has_focus=visible)), - filter=visible) - - -def meta_enter_message(python_input): - """ - Create the `Layout` for the 'Meta+Enter` message. - """ - def get_text_fragments(): - return [('class:accept-message', ' [Meta+Enter] Execute ')] - - def extra_condition(): - " 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) & Condition(extra_condition) - - return ConditionalContainer( - content=Window(FormattedTextControl(get_text_fragments)), - filter=visible) - - -class PtPythonLayout(object): - def __init__(self, python_input, lexer=PythonLexer, extra_body=None, - extra_toolbars=None, extra_buffer_processors=None, - input_buffer_height=None): - D = Dimension - extra_body = [extra_body] if extra_body else [] - extra_toolbars = extra_toolbars or [] - extra_buffer_processors = extra_buffer_processors or [] - input_buffer_height = input_buffer_height or D(min=6) - - search_toolbar = SearchToolbar(python_input.search_buffer) - - def create_python_input_window(): - def menu_position(): - """ - 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 b.complete_state is None and 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 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(), - # 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, - 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) - - root_container = HSplit([ - VSplit([ - HSplit([ - FloatContainer( - content=HSplit( - [create_python_input_window()] + extra_body - ), - floats=[ - Float(xcursor=True, - ycursor=True, - content=ConditionalContainer( - content=CompletionsMenu( - scroll_offset=( - lambda: python_input.completion_menu_scroll_offset), - max_height=12), - filter=show_completions_menu(python_input))), - Float(xcursor=True, - ycursor=True, - content=ConditionalContainer( - content=MultiColumnCompletionsMenu(), - filter=show_multi_column_completions_menu(python_input))), - Float(xcursor=True, - ycursor=True, - content=signature_toolbar(python_input)), - Float(left=2, - bottom=1, - content=exit_confirmation(python_input)), - 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)), - - # 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(root_container) - self.sidebar = sidebar diff --git a/ptpython/prompt_style.py b/ptpython/prompt_style.py deleted file mode 100644 index 58514afe..00000000 --- a/ptpython/prompt_style.py +++ /dev/null @@ -1,76 +0,0 @@ -from __future__ import unicode_literals -from abc import ABCMeta, abstractmethod -from six import with_metaclass - -__all__ = ( - 'PromptStyle', - 'IPythonPrompt', - 'ClassicPrompt', -) - - -class PromptStyle(with_metaclass(ABCMeta, object)): - """ - Base class for all prompts. - """ - @abstractmethod - def in_prompt(self): - " Return the input tokens. " - return [] - - @abstractmethod - def in2_prompt(self, width): - """ - 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): - " Return the output tokens. " - return [] - - -class IPythonPrompt(PromptStyle): - """ - A prompt resembling the IPython prompt. - """ - def __init__(self, python_input): - self.python_input = python_input - - def in_prompt(self): - return [ - ('class:in', 'In ['), - ('class:in.number', '%s' % self.python_input.current_statement_index), - ('class:in', ']: '), - ] - - def in2_prompt(self, width): - return [ - ('class:in', '...: '.rjust(width)), - ] - - def out_prompt(self): - 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): - return [('class:prompt', '>>> ')] - - def in2_prompt(self, width): - return [('class:prompt.dots', '...')] - - def out_prompt(self): - return [] diff --git a/ptpython/python_input.py b/ptpython/python_input.py deleted file mode 100644 index 6de5180a..00000000 --- a/ptpython/python_input.py +++ /dev/null @@ -1,713 +0,0 @@ -""" -Application for reading Python input. -This can be used for creation of Python REPLs. -""" -from __future__ import unicode_literals - -from prompt_toolkit.application import Application, get_app -from prompt_toolkit.application.run_in_terminal import run_coroutine_in_terminal -from prompt_toolkit.auto_suggest import AutoSuggestFromHistory, ConditionalAutoSuggest, ThreadedAutoSuggest -from prompt_toolkit.buffer import Buffer -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.completion import ThreadedCompleter -from prompt_toolkit.document import Document -from prompt_toolkit.enums import DEFAULT_BUFFER, EditingMode -from prompt_toolkit.eventloop.defaults import get_event_loop -from prompt_toolkit.filters import Condition -from prompt_toolkit.history import FileHistory, InMemoryHistory, ThreadedHistory -from prompt_toolkit.input.defaults import create_input -from prompt_toolkit.key_binding import merge_key_bindings, ConditionalKeyBindings, KeyBindings -from prompt_toolkit.key_binding.vi_state import InputMode -from prompt_toolkit.lexers import PygmentsLexer, DynamicLexer, SimpleLexer -from prompt_toolkit.output import ColorDepth -from prompt_toolkit.output.defaults import create_output -from prompt_toolkit.styles import DynamicStyle, SwapLightAndDarkStyleTransformation, ConditionalStyleTransformation, AdjustBrightnessStyleTransformation, merge_style_transformations -from prompt_toolkit.utils import is_windows -from prompt_toolkit.validation import ConditionalValidator - -from .completer import PythonCompleter -from .history_browser import History -from .key_bindings import load_python_bindings, load_sidebar_bindings, load_confirm_exit_bindings -from .layout import PtPythonLayout, CompletionVisualisation -from .prompt_style import IPythonPrompt, ClassicPrompt -from .style import get_all_code_styles, get_all_ui_styles, generate_style -from .utils import get_jedi_script_from_document -from .validator import PythonValidator - -from functools import partial - -import sys -import six -import __future__ - -if six.PY2: - from pygments.lexers import PythonLexer -else: - from pygments.lexers import Python3Lexer as PythonLexer - -__all__ = ( - 'PythonInput', -) - - -class OptionCategory(object): - def __init__(self, title, options): - assert isinstance(title, six.text_type) - assert isinstance(options, list) - - self.title = title - self.options = options - - -class Option(object): - """ - 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, description, get_current_value, get_values): - assert isinstance(title, six.text_type) - assert isinstance(description, six.text_type) - assert callable(get_current_value) - assert callable(get_values) - - self.title = title - self.description = description - self.get_current_value = get_current_value - self.get_values = get_values - - @property - def values(self): - return self.get_values() - - def activate_next(self, _previous=False): - """ - 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): - """ - 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', -} - - -class PythonInput(object): - """ - Prompt for reading Python input. - - :: - - python_input = PythonInput(...) - python_code = python_input.app.run() - """ - def __init__(self, - get_globals=None, get_locals=None, history_filename=None, - vi_mode=False, - - input=None, - output=None, - color_depth=None, - - # For internal use. - extra_key_bindings=None, - _completer=None, _validator=None, - _lexer=None, _extra_buffer_processors=None, - _extra_layout_body=None, _extra_toolbars=None, - _input_buffer_height=None): - - self.get_globals = get_globals or (lambda: {}) - self.get_locals = get_locals or self.get_globals - - self._completer = _completer or PythonCompleter(self.get_globals, self.get_locals) - self._validator = _validator or PythonValidator(self.get_compiler_flags) - self._lexer = _lexer or PygmentsLexer(PythonLexer) - - 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 or [] - 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.show_signature = False - self.show_docstring = False - self.show_meta_enter_message = True - self.completion_visualisation = CompletionVisualisation.MULTI_COLUMN - self.completion_menu_scroll_offset = 1 - - self.show_line_numbers = False - self.show_status_bar = True - self.wrap_lines = True - self.complete_while_typing = True - self.paste_mode = False # When True, don't insert whitespace after newline. - self.confirm_exit = True # Ask for confirmation when Control-D is pressed. - self.accept_input_on_enter = 2 # Accept when pressing Enter 'n' times. - # 'None' means that meta-enter is always required. - self.enable_open_in_editor = True - self.enable_system_bindings = True - self.enable_input_validation = True - self.enable_auto_suggest = False - self.enable_mouse_support = False - self.enable_history_search = 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 = True - self.swap_light_and_dark = False - self.highlight_matching_parenthesis = False - self.show_sidebar = False # Currently show the sidebar. - self.show_sidebar_help = True # When the sidebar is visible, also show the help text. - self.show_exit_confirmation = False # Currently show 'Do you really want to exit?' - self.terminal_title = None # The title to be displayed in the terminal. (None or string.) - self.exit_message = 'Do you really want to exit?' - self.insert_blank_line_after_output = True # (For the REPL.) - - # The buffers. - self.default_buffer = self._create_buffer() - self.search_buffer = Buffer() - self.docstring_buffer = Buffer(read_only=True) - - # Tokens to be shown at the prompt. - self.prompt_style = 'classic' # The currently active style. - - self.all_prompt_styles = { # Styles selectable from the menu. - '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 = get_all_code_styles() - self.ui_styles = get_all_ui_styles() - self._current_code_style_name = 'default' - self._current_ui_style_name = 'default' - - if is_windows(): - self._current_code_style_name = 'win32' - - self._current_style = self._generate_style() - self.color_depth = color_depth or ColorDepth.default() - - self.max_brightness = 1.0 - self.min_brightness = 0.0 - - # Options to be configurable from the sidebar. - self.options = self._create_options() - self.selected_option_index = 0 - - #: Incremeting integer counting the current statement. - self.current_statement_index = 1 - - # Code signatures. (This is set asynchronously after a timeout.) - self.signatures = [] - - # Boolean indicating whether we have a signatures thread running. - # (Never run more than one at the same time.) - self._get_signatures_thread_running = False - - self.output = output or create_output() - self.input = input or create_input(sys.stdin) - - 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) - - self.app = self._create_application() - - if vi_mode: - self.app.editing_mode = EditingMode.VI - - def _accept_handler(self, buff): - 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): - " 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): - " 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 - - def get_compiler_flags(self): - """ - Give the current compiler flags by looking for _Feature instances - in the globals. - """ - flags = 0 - - for value in self.get_globals().values(): - if isinstance(value, __future__._Feature): - flags |= value.compiler_flag - - return flags - - @property - def add_key_binding(self): - """ - Shortcut for adding new key bindings. - (Mostly useful for a .ptpython/config.py file, that receives - a PythonInput/Repl instance as input.) - - :: - - @python_input.add_key_binding(Keys.ControlX, filter=...) - def handler(event): - ... - """ - def add_binding_decorator(*k, **kw): - return self.extra_key_bindings.add(*k, **kw) - return add_binding_decorator - - def install_code_colorscheme(self, name, style_dict): - """ - Install a new code color scheme. - """ - assert isinstance(name, six.text_type) - assert isinstance(style_dict, dict) - - self.code_styles[name] = style_dict - - def use_code_colorscheme(self, name): - """ - 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, style_dict): - """ - Install a new UI color scheme. - """ - assert isinstance(name, six.text_type) - assert isinstance(style_dict, dict) - - self.ui_styles[name] = style_dict - - def use_ui_colorscheme(self, name): - """ - 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): - self.color_depth = depth - - def _set_min_brightness(self, value): - self.min_brightness = value - self.max_brightness = max(self.max_brightness, value) - - def _set_max_brightness(self, value): - self.max_brightness = value - self.min_brightness = min(self.min_brightness, value) - - def _generate_style(self): - """ - 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): - """ - Create a list of `Option` instances for the options sidebar. - """ - def enable(attribute, value=True): - setattr(self, attribute, value) - - # Return `True`, to be able to chain this in the lambdas below. - return True - - def disable(attribute): - setattr(self, attribute, False) - return True - - def simple_option(title, description, field_name, values=None): - " Create Simple on/of option. " - values = values or ['off', 'on'] - - def get_current_value(): - return values[bool(getattr(self, field_name))] - - def get_values(): - 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', [ - simple_option(title='Editing mode', - description='Vi or emacs key bindings.', - field_name='vi_mode', - values=[EditingMode.EMACS, EditingMode.VI]), - 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='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, - get_values=lambda: { - CompletionVisualisation.NONE: lambda: enable('completion_visualisation', CompletionVisualisation.NONE), - CompletionVisualisation.POP_UP: lambda: enable('completion_visualisation', CompletionVisualisation.POP_UP), - CompletionVisualisation.MULTI_COLUMN: lambda: enable('completion_visualisation', CompletionVisualisation.MULTI_COLUMN), - CompletionVisualisation.TOOLBAR: 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: dict((s, partial(enable, 'prompt_style', s)) for s in self.all_prompt_styles)), - 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'), - ]), - OptionCategory('Colors', [ - simple_option(title='Syntax highlighting', - description='Use colors for syntax highligthing', - 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: dict( - (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: dict( - (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: dict( - (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: dict( - ('%.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: dict( - ('%.2f' % value, partial(self._set_max_brightness, value)) - for value in brightness_values) - ), - ]), - ] - - def _create_application(self): - """ - Create an `Application` instance. - """ - return Application( - input=self.input, - output=self.output, - 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) - - def _create_buffer(self): - """ - 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): - return self.app.editing_mode - - @editing_mode.setter - def editing_mode(self, value): - self.app.editing_mode = value - - @property - def vi_mode(self): - return self.editing_mode == EditingMode.VI - - @vi_mode.setter - def vi_mode(self, value): - if value: - self.editing_mode = EditingMode.VI - else: - self.editing_mode = EditingMode.EMACS - - def _on_input_timeout(self, buff): - """ - When there is no input activity, - in another thread, get the signature of the current code. - """ - assert isinstance(buff, Buffer) - app = self.app - - # Never run multiple get-signature threads. - if self._get_signatures_thread_running: - return - self._get_signatures_thread_running = True - - document = buff.document - - def run(): - script = get_jedi_script_from_document(document, self.get_locals(), self.get_globals()) - - # Show signatures in help text. - if script: - try: - signatures = script.call_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 - else: - signatures = [] - - self._get_signatures_thread_running = False - - # Set signatures and redraw if the text didn't change in the - # meantime. Otherwise request new signatures. - if buff.text == document.text: - self.signatures = signatures - - # Set docstring in docstring buffer. - if signatures: - string = signatures[0].docstring() - if not isinstance(string, six.text_type): - string = string.decode('utf-8') - self.docstring_buffer.reset( - document=Document(string, cursor_position=0)) - else: - self.docstring_buffer.reset() - - app.invalidate() - else: - self._on_input_timeout(buff) - - get_event_loop().run_in_executor(run) - - def on_reset(self): - self.signatures = [] - - def enter_history(self): - """ - Display the history. - """ - app = get_app() - app.vi_state.input_mode = InputMode.NAVIGATION - - def done(f): - result = f.result() - if result is not None: - self.default_buffer.text = result - - app.vi_state.input_mode = InputMode.INSERT - - history = History(self, self.default_buffer.document) - - future = run_coroutine_in_terminal(history.app.run_async) - future.add_done_callback(done) diff --git a/ptpython/repl.py b/ptpython/repl.py deleted file mode 100644 index 9ca8ffaf..00000000 --- a/ptpython/repl.py +++ /dev/null @@ -1,351 +0,0 @@ -""" -Utility for creating a Python repl. - -:: - - from ptpython.repl import embed - embed(globals(), locals(), vi_mode=False) - -""" -from __future__ import unicode_literals - -from pygments.lexers import PythonTracebackLexer, PythonLexer -from pygments.token import Token - -from prompt_toolkit.document import Document -from prompt_toolkit.eventloop.defaults import use_asyncio_event_loop -from prompt_toolkit.formatted_text import merge_formatted_text, FormattedText -from prompt_toolkit.formatted_text.utils import fragment_list_width -from prompt_toolkit.utils import DummyContext -from prompt_toolkit.shortcuts import set_title, clear_title -from prompt_toolkit.shortcuts import print_formatted_text -from prompt_toolkit.formatted_text import PygmentsTokens -from prompt_toolkit.patch_stdout import patch_stdout as patch_stdout_context - -from .python_input import PythonInput -from .eventloop import inputhook - -import os -import six -import sys -import traceback -import warnings - -__all__ = ( - 'PythonRepl', - 'enable_deprecation_warnings', - 'run_config', - 'embed', -) - - -class PythonRepl(PythonInput): - def __init__(self, *a, **kw): - self._startup_paths = kw.pop('startup_paths', None) - super(PythonRepl, self).__init__(*a, **kw) - self._load_start_paths() - - def _load_start_paths(self): - " 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') - six.exec_(code, self.get_globals(), self.get_locals()) - else: - output = self.app.output - output.write('WARNING | File not found: {}\n\n'.format(path)) - - def run(self): - if self.terminal_title: - set_title(self.terminal_title) - - while True: - # Run the UI. - try: - text = self.app.run(inputhook=inputhook) - except EOFError: - return - except KeyboardInterrupt: - # Abort - try again. - self.default_buffer.document = Document() - else: - self._process_text(text) - - if self.terminal_title: - clear_title() - - def _process_text(self, text): - line = self.default_buffer.text - - if line and not line.isspace(): - try: - # Eval and print. - self._execute(line) - except KeyboardInterrupt as e: # KeyboardInterrupt doesn't inherit from Exception. - self._handle_keyboard_interrupt(e) - except Exception as e: - self._handle_exception(e) - - if self.insert_blank_line_after_output: - self.app.output.write('\n') - - self.current_statement_index += 1 - self.signatures = [] - - def _execute(self, line): - """ - Evaluate the line and print the result. - """ - output = self.app.output - - # 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, '') - - def compile_with_flags(code, mode): - " Compile code with the right compiler flags. " - return compile(code, '', mode, - flags=self.get_compiler_flags(), - dont_inherit=True) - - if line.lstrip().startswith('\x1a'): - # When the input starts with Ctrl-Z, quit the REPL. - self.app.exit() - - elif line.lstrip().startswith('!'): - # Run as shell command - os.system(line[1:]) - else: - # Try eval first - try: - code = compile_with_flags(line, 'eval') - result = eval(code, self.get_globals(), self.get_locals()) - - locals = self.get_locals() - locals['_'] = locals['_%i' % self.current_statement_index] = result - - if result is not None: - out_prompt = self.get_output_prompt() - - try: - result_str = '%r\n' % (result, ) - except UnicodeDecodeError: - # In Python 2: `__repr__` should return a bytestring, - # so to put it in a unicode context could raise an - # exception that the 'ascii' codec can't decode certain - # characters. Decode as utf-8 in that case. - result_str = '%s\n' % repr(result).decode('utf-8') - - # Align every line to the first one. - line_sep = '\n' + ' ' * fragment_list_width(out_prompt) - result_str = line_sep.join(result_str.splitlines()) + '\n' - - # Write output tokens. - if self.enable_syntax_highlighting: - formatted_output = merge_formatted_text([ - out_prompt, - PygmentsTokens(list(_lex_python_result(result_str))), - ]) - else: - formatted_output = FormattedText( - out_prompt + [('', result_str)]) - - print_formatted_text( - formatted_output, style=self._current_style, - style_transformation=self.style_transformation, - include_default_pygments_style=False) - - # If not a valid `eval` expression, run using `exec` instead. - except SyntaxError: - code = compile_with_flags(line, 'exec') - six.exec_(code, self.get_globals(), self.get_locals()) - - output.flush() - - def _handle_exception(self, e): - output = self.app.output - - # Instead of just calling ``traceback.format_exc``, we take the - # traceback and skip the bottom calls of this framework. - t, v, tb = sys.exc_info() - - # Required for pdb.post_mortem() to work. - sys.last_type, sys.last_value, sys.last_traceback = t, v, tb - - tblist = traceback.extract_tb(tb) - - for line_nr, tb_tuple in enumerate(tblist): - if tb_tuple[0] == '': - tblist = tblist[line_nr:] - break - - l = traceback.format_list(tblist) - if l: - l.insert(0, "Traceback (most recent call last):\n") - l.extend(traceback.format_exception_only(t, v)) - - # For Python2: `format_list` and `format_exception_only` return - # non-unicode strings. Ensure that everything is unicode. - if six.PY2: - l = [i.decode('utf-8') if isinstance(i, six.binary_type) else i for i in l] - - tb = ''.join(l) - - # Format exception and write to output. - # (We use the default style. Most other styles result - # in unreadable colors for the traceback.) - if self.enable_syntax_highlighting: - tokens = list(_lex_python_traceback(tb)) - else: - tokens = [(Token, tb)] - - print_formatted_text( - PygmentsTokens(tokens), style=self._current_style, - style_transformation=self.style_transformation, - include_default_pygments_style=False) - - output.write('%s\n' % e) - output.flush() - - def _handle_keyboard_interrupt(self, e): - output = self.app.output - - output.write('\rKeyboardInterrupt\n\n') - output.flush() - - -def _lex_python_traceback(tb): - " Return token list for traceback string. " - lexer = PythonTracebackLexer() - return lexer.get_tokens(tb) - - -def _lex_python_result(tb): - " Return token list for Python string. " - lexer = PythonLexer() - return lexer.get_tokens(tb) - - -def enable_deprecation_warnings(): - """ - Show deprecation warnings, when they are triggered directly by actions in - the REPL. This is recommended to call, before calling `embed`. - - e.g. This will show an error message when the user imports the 'sha' - library on Python 2.7. - """ - warnings.filterwarnings('default', category=DeprecationWarning, - module='__main__') - - -def run_config(repl, config_file='~/.ptpython/config.py'): - """ - Execute REPL config file. - - :param repl: `PythonInput` instance. - :param config_file: Path of the configuration file. - """ - assert isinstance(repl, PythonInput) - assert isinstance(config_file, six.text_type) - - # Expand tildes. - config_file = os.path.expanduser(config_file) - - def enter_to_continue(): - six.moves.input('\nPress ENTER to continue...') - - # Check whether this file exists. - if not os.path.exists(config_file): - print('Impossible to read %r' % config_file) - enter_to_continue() - return - - # Run the config file in an empty namespace. - try: - namespace = {} - - with open(config_file, 'rb') as f: - code = compile(f.read(), config_file, 'exec') - six.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=None, - vi_mode=False, history_filename=None, title=None, - startup_paths=None, patch_stdout=False, return_asyncio_coroutine=False): - """ - 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.) - """ - assert configure is None or callable(configure) - - # Default globals/locals - if globals is None: - globals = { - '__name__': '__main__', - '__package__': None, - '__doc__': None, - '__builtins__': six.moves.builtins, - } - - locals = locals or globals - - def get_globals(): - return globals - - def get_locals(): - return locals - - # Create eventloop. - if return_asyncio_coroutine: - use_asyncio_event_loop() - - # 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) - - app = repl.app - - # Start repl. - patch_context = patch_stdout_context() if patch_stdout else DummyContext() - - if return_asyncio_coroutine: # XXX - def coroutine(): - with patch_context: - while True: - iterator = iter(app.run_async().to_asyncio_future()) - try: - while True: - yield next(iterator) - except StopIteration as exc: - text = exc.args[0] - repl._process_text(text) - return coroutine() - else: - with patch_context: - repl.run() diff --git a/ptpython/style.py b/ptpython/style.py deleted file mode 100644 index 15c5b2ad..00000000 --- a/ptpython/style.py +++ /dev/null @@ -1,188 +0,0 @@ -from __future__ import unicode_literals - -from prompt_toolkit.styles import Style, merge_styles -from prompt_toolkit.styles.pygments import style_from_pygments_cls -from prompt_toolkit.utils import is_windows, is_conemu_ansi, is_windows_vt100_supported -from pygments.styles import get_style_by_name, get_all_styles - -__all__ = ( - 'get_all_code_styles', - 'get_all_ui_styles', - 'generate_style', -) - - -def get_all_code_styles(): - """ - Return a mapping from style names to their classes. - """ - result = dict((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(): - """ - 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, ui_style): - """ - 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', - - # 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.currentname': '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.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.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 deleted file mode 100644 index 2cdf2491..00000000 --- a/ptpython/utils.py +++ /dev/null @@ -1,127 +0,0 @@ -""" -For internal use only. -""" -from __future__ import unicode_literals - -from prompt_toolkit.mouse_events import MouseEventType -import re - -__all__ = ( - 'has_unclosed_brackets', - 'get_jedi_script_from_document', - 'document_is_multiline_python', -) - - -def has_unclosed_brackets(text): - """ - 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, locals, globals): - import jedi # We keep this import in-line, to improve start-up time. - # Importing Jedi is 'slow'. - - try: - return jedi.Interpreter( - document.text, - column=document.cursor_position_col, - line=document.cursor_position_row + 1, - 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: - # Workaroud 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): - """ - Determine whether this is a multiline Python document. - """ - def ends_in_multiline_string(): - """ - ``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(): - 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 - - -def if_mousedown(handler): - """ - 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): - if mouse_event.event_type == MouseEventType.MOUSE_DOWN: - return handler(mouse_event) - else: - return NotImplemented - return handle_if_mouse_down diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..00e2d5f8 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,95 @@ +[project] +name = "ptpython" +version = "3.0.30" +description = "Python REPL build on top of prompt_toolkit" +readme = "README.rst" +authors = [{ name = "Jonathan Slenders" }] +classifiers = [ + "License :: OSI Approved :: BSD License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python", +] +requires-python = ">=3.8" +dependencies = [ + "appdirs", + "jedi>=0.16.0", + # Use prompt_toolkit 3.0.43, because of `OneStyleAndTextTuple` import. + "prompt_toolkit>=3.0.43,<3.1.0", + "pygments", +] + + +[project.urls] +Homepage = "https://github.com/prompt-toolkit/ptpython" +Changelog = "https://github.com/prompt-toolkit/ptpython/blob/master/CHANGELOG" +"Bug Tracker" = "https://github.com/prompt-toolkit/ptpython/issues" +"Source Code" = "https://github.com/prompt-toolkit/ptpython" + + +[project.scripts] +ptpython = "ptpython.entry_points.run_ptpython:run" +ptipython = "ptpython.entry_points.run_ptipython:run" + + +[project.optional-dependencies] +ptipython = ["ipython"] # For ptipython, we need to have IPython + + +[tool.mypy] +ignore_missing_imports = true +no_implicit_optional = true +platform = "win32" +strict_equality = true +strict_optional = true + + +[tool.ruff] +target-version = "py37" +lint.select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "C", # flake8-comprehensions + "T", # Print. + "I", # isort + # "B", # flake8-bugbear + "UP", # pyupgrade + "RUF100", # unused-noqa + "Q", # quotes +] +lint.ignore = [ + "E501", # Line too long, handled by black + "C901", # Too complex + "E722", # bare except. +] + + +[tool.ruff.lint.per-file-ignores] +"examples/*" = ["T201"] # Print allowed in examples. +"examples/ptpython_config/config.py" = ["F401"] # Unused imports in config. +"src/ptpython/entry_points/run_ptipython.py" = ["T201", "F401"] # Print, import usage. +"src/ptpython/entry_points/run_ptpython.py" = ["T201"] # Print usage. +"src/ptpython/ipython.py" = ["T100"] # Import usage. +"src/ptpython/repl.py" = ["T201"] # Print usage. +"src/ptpython/printer.py" = ["T201"] # Print usage. + + +[tool.ruff.lint.isort] +known-first-party = ["ptpython"] +known-third-party = ["prompt_toolkit", "pygments", "asyncssh"] + +[tool.typos.default] +extend-ignore-re = [ + "impotr" # Intentional typo in: ./examples/ptpython_config/config.py +] + +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" diff --git a/setup.py b/setup.py deleted file mode 100644 index 01868e8d..00000000 --- a/setup.py +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env python -import os -import sys -from setuptools import setup, find_packages - -with open(os.path.join(os.path.dirname(__file__), 'README.rst')) as f: - long_description = f.read() - - -setup( - name='ptpython', - author='Jonathan Slenders', - version='2.0.4', - url='https://github.com/jonathanslenders/ptpython', - description='Python REPL build on top of prompt_toolkit', - long_description=long_description, - packages=find_packages('.'), - install_requires = [ - 'docopt', - 'jedi>=0.9.0', - 'prompt_toolkit>=2.0.6,<2.1.0', - 'pygments', - ], - entry_points={ - 'console_scripts': [ - 'ptpython = ptpython.entry_points.run_ptpython:run', - 'ptipython = ptpython.entry_points.run_ptipython:run', - 'ptpython%s = ptpython.entry_points.run_ptpython:run' % sys.version_info[0], - 'ptpython%s.%s = ptpython.entry_points.run_ptpython:run' % sys.version_info[:2], - 'ptipython%s = ptpython.entry_points.run_ptipython:run' % sys.version_info[0], - 'ptipython%s.%s = ptpython.entry_points.run_ptipython:run' % sys.version_info[:2], - ] - }, - extras_require={ - 'ptipython': ['ipython'] # For ptipython, we need to have IPython - } -) diff --git a/src/ptpython/__init__.py b/src/ptpython/__init__.py new file mode 100644 index 00000000..63c6233d --- /dev/null +++ b/src/ptpython/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from .repl import embed + +__all__ = ["embed"] diff --git a/ptpython/__main__.py b/src/ptpython/__main__.py similarity index 74% rename from ptpython/__main__.py rename to src/ptpython/__main__.py index 7e4cbabe..3a2f7ddf 100644 --- a/ptpython/__main__.py +++ b/src/ptpython/__main__.py @@ -1,7 +1,9 @@ """ Make `python -m ptpython` an alias for running `./ptpython`. """ -from __future__ import unicode_literals + +from __future__ import annotations + from .entry_points.run_ptpython import run run() diff --git a/src/ptpython/completer.py b/src/ptpython/completer.py new file mode 100644 index 00000000..e8bab285 --- /dev/null +++ b/src/ptpython/completer.py @@ -0,0 +1,689 @@ +from __future__ import annotations + +import ast +import collections.abc as collections_abc +import inspect +import keyword +import re +from enum import Enum +from itertools import islice +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([^\n'\\]|\\.)*) | # Inside a single quoted string. + " (?P([^\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(obj: object, key: object) -> Callable[[], str]: + "Abbreviate meta text, make sure it fits on one line." + cached_result: str | None = None + + # We return a function, so that it gets computed when it's needed. + # When there are many completions, that improves the performance + # quite a bit (for the multi-column completion menu, we only need + # to display one meta text). + # Note that we also do the lookup itself in here (`obj[key]`), + # because this part can also be slow for some mapping + # implementations. + def get_value_repr() -> str: + nonlocal cached_result + if cached_result is not None: + return cached_result + + try: + value = obj[key] # type: ignore + + text = self._do_repr(value) + except BaseException: + return "-" + + # Take first line, if multiple lines. + if "\n" in text: + text = text.split("\n", 1)[0] + "..." + + cached_result = text + return text + + return get_value_repr + + match = self.item_lookup_pattern.search(document.text_before_cursor) + if match is not None: + 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_str = str(key) + for k in [key, key + '"', key + "'"]: + try: + key_obj_str = str(ast.literal_eval(k)) + except (SyntaxError, ValueError): + continue + else: + break + + for k in result: + if str(k).startswith(key_obj_str): + try: + k_repr = self._do_repr(k) + yield Completion( + k_repr + "]", + -len(key), + display=f"[{k_repr}]", + display_meta=meta_repr(result, k), + ) + 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( + # Limit at 5k completions for performance. + islice(self.completer.get_completions(document, complete_event), 0, 5000) + ) + complete_private_attributes = self.complete_private_attributes() + hide_private = False + + 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/__init__.py b/src/ptpython/contrib/__init__.py similarity index 100% rename from ptpython/__init__.py rename to src/ptpython/contrib/__init__.py diff --git a/ptpython/contrib/asyncssh_repl.py b/src/ptpython/contrib/asyncssh_repl.py similarity index 52% rename from ptpython/contrib/asyncssh_repl.py rename to src/ptpython/contrib/asyncssh_repl.py index a4df4449..a86737b6 100644 --- a/ptpython/contrib/asyncssh_repl.py +++ b/src/ptpython/contrib/asyncssh_repl.py @@ -6,76 +6,71 @@ should make sure not to use Python 3-only syntax, because this package should be installable in Python 2 as well! """ -from __future__ import unicode_literals + +from __future__ import annotations import asyncio -import asyncssh +from typing import Any, AnyStr, TextIO, cast -from prompt_toolkit.input import PipeInput -from prompt_toolkit.interface import CommandLineInterface -from prompt_toolkit.layout.screen import Size -from prompt_toolkit.shortcuts import create_asyncio_eventloop -from prompt_toolkit.terminal.vt100_output import Vt100_Output +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', -) +__all__ = ["ReplSSHServerSession"] -class ReplSSHServerSession(asyncssh.SSHServerSession): +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, get_locals=None): - assert callable(get_globals) - assert get_locals is None or callable(get_locals) - self._chan = None + def __init__( + self, get_globals: _GetNamespace, get_locals: _GetNamespace | None = None + ) -> None: + self._chan: Any = None - def _globals(): + def _globals() -> _Namespace: data = get_globals() - data.setdefault('print', self._print) + data.setdefault("print", self._print) return data - repl = PythonRepl(get_globals=_globals, - get_locals=get_locals or _globals) - - # 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. - repl.enable_open_in_editor = False - repl.enable_system_bindings = False - # 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 = PipeInput() + self._input_pipe = create_pipe_input() # Output object. Don't render to the real stdout, but write everything # in the SSH channel. - class Stdout(object): - def write(s, data): + class Stdout: + def write(s, data: str) -> None: if self._chan is not None: - self._chan.write(data.replace('\n', '\r\n')) + data = data.replace("\n", "\r\n") + self._chan.write(data) - def flush(s): + def flush(s) -> None: pass - # Create command line interface. - self.cli = CommandLineInterface( - application=repl.create_application(), - eventloop=create_asyncio_eventloop(), + self.repl = PythonRepl( + get_globals=_globals, + get_locals=get_locals or _globals, input=self._input_pipe, - output=Vt100_Output(Stdout(), self._get_size)) + output=Vt100_Output(cast(TextIO, Stdout()), self._get_size), + ) - self._callbacks = self.cli.create_eventloop_callbacks() + # 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): + def _get_size(self) -> Size: """ Callable that returns the current `Size`, required by Vt100_Output. """ @@ -85,49 +80,47 @@ def _get_size(self): width, height, pixwidth, pixheight = self._chan.get_terminal_size() return Size(rows=height, columns=width) - def connection_made(self, chan): + def connection_made(self, chan: Any) -> None: """ Client connected, run repl in coroutine. """ self._chan = chan # Run REPL interface. - f = asyncio.ensure_future(self.cli.run_async()) + f = asyncio.ensure_future(self.repl.run_async()) # Close channel when done. - def done(_): + def done(_: object) -> None: chan.close() self._chan = None + f.add_done_callback(done) - def shell_requested(self): + def shell_requested(self) -> bool: return True - def terminal_size_changed(self, width, height, pixwidth, pixheight): + def terminal_size_changed( + self, width: int, height: int, pixwidth: int, pixheight: int + ) -> None: """ When the terminal size changes, report back to CLI. """ - self._callbacks.terminal_size_changed() + self.repl.app._on_resize() - def data_received(self, data, datatype): + def data_received(self, data: AnyStr, datatype: int | None) -> None: """ When data is received, send to inputstream of the CLI and repaint. """ - self._input_pipe.send(data) + self._input_pipe.send(data) # type: ignore - def _print(self, *data, **kw): + def _print( + self, *data: object, sep: str = " ", end: str = "\n", file: Any = None + ) -> None: """ - _print(self, *data, sep=' ', end='\n', file=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.) - sep = kw.pop('sep', ' ') - end = kw.pop('end', '\n') - _ = kw.pop('file', None) - assert not kw, 'Too many keyword-only arguments' - - data = sep.join(map(str, data)) - self._chan.write(data + end) + data_as_str = sep.join(map(str, data)) + self._chan.write(data_as_str + end) diff --git a/ptpython/contrib/__init__.py b/src/ptpython/entry_points/__init__.py similarity index 100% rename from ptpython/contrib/__init__.py rename to src/ptpython/entry_points/__init__.py diff --git a/src/ptpython/entry_points/run_ptipython.py b/src/ptpython/entry_points/run_ptipython.py new file mode 100644 index 00000000..b660a0ac --- /dev/null +++ b/src/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/src/ptpython/entry_points/run_ptpython.py b/src/ptpython/entry_points/run_ptpython.py new file mode 100644 index 00000000..d083858d --- /dev/null +++ b/src/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 importlib import metadata +from textwrap import dedent +from typing import Protocol + +import appdirs +from prompt_toolkit.formatted_text import HTML +from prompt_toolkit.shortcuts import print_formatted_text + +from ptpython.repl import PythonRepl, embed, enable_deprecation_warnings, run_config + +__all__ = ["create_parser", "get_config_and_history_file", "run"] + + +class _SupportsWrite(Protocol): + def write(self, s: str, /) -> object: ... + + +class _Parser(argparse.ArgumentParser): + def print_help(self, file: _SupportsWrite | 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( + " ~/.ptpython/config.py is deprecated, move your configuration to %s\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( + " ~/.ptpython/history is deprecated, move your history to %s\n" + ) + % history_file + ) + history_file = legacy_history_file + + # Print warnings. + if warnings: + print_formatted_text(HTML("Warning:")) + 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/src/ptpython/eventloop.py similarity index 72% rename from ptpython/eventloop.py rename to src/ptpython/eventloop.py index 43fe0549..a6462748 100644 --- a/ptpython/eventloop.py +++ b/src/ptpython/eventloop.py @@ -3,29 +3,33 @@ 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 registeres it's callbacks in ``PyOS_InputHook`` to integrate +(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 -__all__ = ( - 'inputhook', -) +from prompt_toolkit.eventloop import InputHookContext + +__all__ = ["inputhook"] -def _inputhook_tk(inputhook_context): +def _inputhook_tk(inputhook_context: InputHookContext) -> None: """ Inputhook for Tk. Run the Tk eventloop until prompt-toolkit needs to process the next input. """ # Get the current TK application. import _tkinter # Keep this imports inline! - from six.moves import tkinter - root = tkinter._default_root + import tkinter - def wait_using_filehandler(): + 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. @@ -33,7 +37,8 @@ def wait_using_filehandler(): # Add a handler that sets the stop flag when `prompt-toolkit` has input # to process. stop = [False] - def done(*a): + + def done(*a: object) -> None: stop[0] = True root.createfilehandler(inputhook_context.fileno(), _tkinter.READABLE, done) @@ -45,26 +50,26 @@ def done(*a): root.deletefilehandler(inputhook_context.fileno()) - def wait_using_polling(): + def wait_using_polling() -> None: """ Windows TK doesn't support 'createfilehandler'. So, run the TK eventloop and poll until input is ready. """ while not inputhook_context.input_is_ready(): while root.dooneevent(_tkinter.ALL_EVENTS | _tkinter.DONT_WAIT): - pass + pass # Sleep to make the CPU idle, but not too long, so that the UI # stays responsive. - time.sleep(.01) + time.sleep(0.01) if root is not None: - if hasattr(root, 'createfilehandler'): + if hasattr(root, "createfilehandler"): wait_using_filehandler() else: wait_using_polling() -def inputhook(inputhook_context): +def inputhook(inputhook_context: InputHookContext) -> None: # Only call the real input hook when the 'Tkinter' library was loaded. - if 'Tkinter' in sys.modules or 'tkinter' in sys.modules: + if "Tkinter" in sys.modules or "tkinter" in sys.modules: _inputhook_tk(inputhook_context) diff --git a/ptpython/filters.py b/src/ptpython/filters.py similarity index 53% rename from ptpython/filters.py rename to src/ptpython/filters.py index 8ddc3c6a..a2079fd3 100644 --- a/ptpython/filters.py +++ b/src/ptpython/filters.py @@ -1,38 +1,39 @@ -from __future__ import unicode_literals +from __future__ import annotations + +from typing import TYPE_CHECKING from prompt_toolkit.filters import Filter -__all__ = ( - 'HasSignature', - 'ShowSidebar', - 'ShowSignature', - 'ShowDocstring', -) +if TYPE_CHECKING: + from .python_input import PythonInput + +__all__ = ["HasSignature", "ShowSidebar", "ShowSignature", "ShowDocstring"] class PythonInputFilter(Filter): - def __init__(self, python_input): + def __init__(self, python_input: PythonInput) -> None: + super().__init__() self.python_input = python_input - def __call__(self): + def __call__(self) -> bool: raise NotImplementedError class HasSignature(PythonInputFilter): - def __call__(self): + def __call__(self) -> bool: return bool(self.python_input.signatures) class ShowSidebar(PythonInputFilter): - def __call__(self): + def __call__(self) -> bool: return self.python_input.show_sidebar class ShowSignature(PythonInputFilter): - def __call__(self): + def __call__(self) -> bool: return self.python_input.show_signature class ShowDocstring(PythonInputFilter): - def __call__(self): + def __call__(self) -> bool: return self.python_input.show_docstring diff --git a/ptpython/history_browser.py b/src/ptpython/history_browser.py similarity index 52% rename from ptpython/history_browser.py rename to src/ptpython/history_browser.py index 3d14067a..72bc576d 100644 --- a/ptpython/history_browser.py +++ b/src/ptpython/history_browser.py @@ -4,7 +4,11 @@ `create_history_application` creates an `Application` instance that runs will run as a sub application of the Repl/PythonInput. """ -from __future__ import unicode_literals + +from __future__ import annotations + +from functools import partial +from typing import TYPE_CHECKING, Callable from prompt_toolkit.application import Application from prompt_toolkit.application.current import get_app @@ -12,36 +16,57 @@ 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.layout.containers import HSplit, VSplit, Window, FloatContainer, Float, ConditionalContainer, Container, ScrollOffsets, WindowAlign -from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl +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 +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 .utils import if_mousedown - from ptpython.layout import get_inputmode_fragments -from functools import partial -import six -if six.PY2: - from pygments.lexers import PythonLexer -else: - from pygments.lexers import Python3Lexer as PythonLexer +from .utils import if_mousedown + +if TYPE_CHECKING: + from typing_extensions import TypeAlias + from .python_input import PythonInput HISTORY_COUNT = 2000 -__all__ = ( - 'HistoryLayout', -) +__all__ = ["HistoryLayout", "PythonHistory"] + +E: TypeAlias = KeyPressEvent HELP_TEXT = """ This interface is meant to select multiple lines from the @@ -84,128 +109,153 @@ class BORDER: - " Box drawing characters. " - HORIZONTAL = '\u2501' - VERTICAL = '\u2503' - TOP_LEFT = '\u250f' - TOP_RIGHT = '\u2513' - BOTTOM_LEFT = '\u2517' - BOTTOM_RIGHT = '\u251b' - LIGHT_VERTICAL = '\u2502' + "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, body): + +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. """ - assert isinstance(title, six.text_type) - assert isinstance(body, Container) return Frame(body=body, title=title) -class HistoryLayout(object): +class HistoryLayout: """ Create and return a `Container` instance for the history application. """ - def __init__(self, history): + + def __init__(self, history: PythonHistory) -> None: search_toolbar = SearchToolbar() self.help_buffer_control = BufferControl( - buffer=history.help_buffer, - lexer=PygmentsLexer(RstLexer)) + buffer=history.help_buffer, lexer=PygmentsLexer(RstLexer) + ) help_window = _create_popup_window( - title='History Help', + title="History Help", body=Window( content=self.help_buffer_control, right_margins=[ScrollbarMargin(display_arrows=True)], - scroll_offsets=ScrollOffsets(top=2, bottom=2))) + 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)) + lexer=PygmentsLexer(PythonLexer), + ) self.history_buffer_control = BufferControl( buffer=history.history_buffer, lexer=PygmentsLexer(PythonLexer), search_buffer_control=search_toolbar.control, - preview_search=True) + 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'), - ]) + 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(): - return [('class:status-bar.title', 'History browser - Insert from history')] +def _get_top_toolbar_fragments() -> StyleAndTextTuples: + return [("class:status-bar.title", "History browser - Insert from history")] -def _get_bottom_toolbar_fragments(history): +def _get_bottom_toolbar_fragments(history: PythonHistory) -> StyleAndTextTuples: python_input = history.python_input + @if_mousedown - def f1(mouse_event): + def f1(mouse_event: MouseEvent) -> None: _toggle_help(history) @if_mousedown - def tab(mouse_event): + def tab(mouse_event: MouseEvent) -> None: _select_other_window(history) - return [ - ('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), - ] + 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): @@ -213,14 +263,17 @@ class HistoryMargin(Margin): Margin for the history buffer. This displays a green bar for the selected entries. """ - def __init__(self, history): + + def __init__(self, history: PythonHistory) -> None: self.history_buffer = history.history_buffer self.history_mapping = history.history_mapping - def get_width(self, ui_content): + def get_width(self, get_ui_content: Callable[[], UIContent]) -> int: return 2 - def create_margin(self, window_render_info, width, height): + def create_margin( + self, window_render_info: WindowRenderInfo, width: int, height: int + ) -> StyleAndTextTuples: document = self.history_buffer.document lines_starting_new_entries = self.history_mapping.lines_starting_new_entries @@ -229,7 +282,7 @@ def create_margin(self, window_render_info, width, height): current_lineno = document.cursor_position_row visible_line_to_input_line = window_render_info.visible_line_to_input_line - result = [] + result: StyleAndTextTuples = [] for y in range(height): line_number = visible_line_to_input_line.get(y) @@ -237,20 +290,20 @@ def create_margin(self, window_render_info, width, height): # Show stars at the start of each entry. # (Visualises multiline entries.) if line_number in lines_starting_new_entries: - char = '*' + char = "*" else: - char = ' ' + char = " " if line_number in selected_lines: - t = 'class:history-line,selected' + t = "class:history-line,selected" else: - t = 'class:history-line' + t = "class:history-line" if line_number == current_lineno: - t = t + ',current' + t = t + ",current" result.append((t, char)) - result.append(('', '\n')) + result.append(("", "\n")) return result @@ -259,40 +312,48 @@ class ResultMargin(Margin): """ The margin to be shown in the result pane. """ - def __init__(self, history): + + def __init__(self, history: PythonHistory) -> None: self.history_mapping = history.history_mapping self.history_buffer = history.history_buffer - def get_width(self, ui_content): + def get_width(self, get_ui_content: Callable[[], UIContent]) -> int: return 2 - def create_margin(self, window_render_info, width, height): + def create_margin( + self, window_render_info: WindowRenderInfo, width: int, height: int + ) -> StyleAndTextTuples: document = self.history_buffer.document current_lineno = document.cursor_position_row - offset = self.history_mapping.result_line_offset #original_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 = [] + 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 = '' + 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' + t = "class:history-line,selected,current" else: - t = 'class:history-line,selected' + t = "class:history-line,selected" - result.append((t, ' ')) - result.append(('', '\n')) + result.append((t, " ")) + result.append(("", "\n")) return result - def invalidation_hash(self, document): + def invalidation_hash(self, document: Document) -> int: return document.cursor_position_row @@ -300,37 +361,49 @@ class GrayExistingText(Processor): """ Turn the existing input, before and after the inserted code gray. """ - def __init__(self, history_mapping): + + def __init__(self, history_mapping: HistoryMapping) -> None: self.history_mapping = history_mapping - self._lines_before = len(history_mapping.original_document.text_before_cursor.splitlines()) + self._lines_before = len( + history_mapping.original_document.text_before_cursor.splitlines() + ) - def apply_transformation(self, transformation_input): + def apply_transformation( + self, transformation_input: TransformationInput + ) -> Transformation: lineno = transformation_input.lineno fragments = transformation_input.fragments - if (lineno < self._lines_before or - lineno >= self._lines_before + len(self.history_mapping.selected_lines)): + 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)]) + return Transformation(fragments=[("class:history.existing-input", text)]) else: return Transformation(fragments=fragments) -class HistoryMapping(object): +class HistoryMapping: """ Keep a list of all the lines from the history and the selected lines. """ - def __init__(self, history, python_history, original_document): + + def __init__( + self, + history: PythonHistory, + python_history: History, + original_document: Document, + ) -> None: self.history = history self.python_history = python_history self.original_document = original_document self.lines_starting_new_entries = set() - self.selected_lines = set() + self.selected_lines: set[int] = set() # Process history. history_strings = python_history.get_strings() - history_lines = [] + history_lines: list[str] = [] for entry_nr, entry in list(enumerate(history_strings))[-HISTORY_COUNT:]: self.lines_starting_new_entries.add(len(history_lines)) @@ -339,10 +412,12 @@ def __init__(self, history, python_history, original_document): history_lines.append(line) if len(history_strings) > HISTORY_COUNT: - history_lines[0] = '# *** History has been truncated to %s lines ***' % HISTORY_COUNT + history_lines[0] = ( + f"# *** History has been truncated to {HISTORY_COUNT} lines ***" + ) self.history_lines = history_lines - self.concatenated_history = '\n'.join(history_lines) + self.concatenated_history = "\n".join(history_lines) # Line offset. if self.original_document.text_before_cursor: @@ -350,7 +425,7 @@ def __init__(self, history, python_history, original_document): else: self.result_line_offset = 0 - def get_new_document(self, cursor_pos=None): + def get_new_document(self, cursor_pos: int | None = None) -> Document: """ Create a `Document` instance that contains the resulting text. """ @@ -369,20 +444,19 @@ def get_new_document(self, cursor_pos=None): lines.append(self.original_document.text_after_cursor) # Create `Document` with cursor at the right position. - text = '\n'.join(lines) + 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): + def update_default_buffer(self) -> None: b = self.history.default_buffer - b.set_document( - self.get_new_document(b.cursor_position), bypass_readonly=True) + b.set_document(self.get_new_document(b.cursor_position), bypass_readonly=True) -def _toggle_help(history): - " Display/hide help. " +def _toggle_help(history: PythonHistory) -> None: + "Display/hide help." help_buffer_control = history.history_layout.help_buffer_control if history.app.layout.current_control == help_buffer_control: @@ -391,8 +465,8 @@ def _toggle_help(history): history.app.layout.current_control = help_buffer_control -def _select_other_window(history): - " Toggle focus between left/right window. " +def _select_other_window(history: PythonHistory) -> None: + "Toggle focus between left/right window." current_buffer = history.app.current_buffer layout = history.history_layout.layout @@ -403,15 +477,19 @@ def _select_other_window(history): layout.current_control = history.history_layout.history_buffer_control -def create_key_bindings(history, python_input, history_mapping): +def create_key_bindings( + history: PythonHistory, + python_input: PythonInput, + history_mapping: HistoryMapping, +) -> KeyBindings: """ Key bindings. """ bindings = KeyBindings() handle = bindings.add - @handle(' ', filter=has_focus(history.history_buffer)) - def _(event): + @handle(" ", filter=has_focus(history.history_buffer)) + def _(event: E) -> None: """ Space: select/deselect line from history pane. """ @@ -433,19 +511,22 @@ def _(event): # 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_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): + @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. """ @@ -463,64 +544,66 @@ def _(event): 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): - " Select other window. " + 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): - " Switch between Emacs/Vi mode. " + @handle("f4") + def _(event: E) -> None: + "Switch between Emacs/Vi mode." python_input.vi_mode = not python_input.vi_mode - @handle('f1') - def _(event): - " Display/hide help. " + @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): - " Leave help. " + @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): - " Cancel and go back. " + @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): - " Accept input. " + @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): - " Suspend to background. " + @handle("c-z", filter=enable_system_bindings) + def _(event: E) -> None: + "Suspend to background." event.app.suspend_to_background() return bindings -class History(object): - def __init__(self, python_input, original_document): +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 retuns the selected lines. + When this application runs and returns, it returns the selected lines. """ self.python_input = python_input @@ -530,44 +613,50 @@ def __init__(self, python_input, original_document): document = Document(history_mapping.concatenated_history) document = Document( document.text, - cursor_position=document.cursor_position + document.get_start_of_line_position()) + 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=( - lambda buff: get_app().exit(result=self.default_buffer.text)), - read_only=True) + 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 + read_only=True, ) + self.help_buffer = Buffer(document=Document(HELP_TEXT, 0), read_only=True) + self.history_layout = HistoryLayout(self) - self.app = Application( + self.app: Application[str] = Application( layout=self.history_layout.layout, full_screen=True, style=python_input._current_style, mouse_support=Condition(lambda: python_input.enable_mouse_support), - key_bindings=create_key_bindings(self, python_input, history_mapping) + key_bindings=create_key_bindings(self, python_input, history_mapping), ) - def _default_buffer_pos_changed(self, _): - """ When the cursor changes in the default buffer. Synchronize with - history buffer. """ + 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 + 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 @@ -576,19 +665,26 @@ def _default_buffer_pos_changed(self, _): 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, _): - """ When the cursor changes in the history buffer. Synchronize. """ + 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) - + 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/src/ptpython/ipython.py b/src/ptpython/ipython.py new file mode 100644 index 00000000..0692214d --- /dev/null +++ b/src/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%)( + (?Ppycat|run|loadpy|load) \s+ (?P[^\s]+) | + (?Pcat) \s+ (?P[^\s]+) | + (?Ppushd|cd|ls) \s+ (?P[^\s]+) | + (?Ppdb) \s+ (?P[^\s]+) | + (?Pautocall) \s+ (?P[^\s]+) | + (?Ptime|timeit|prun) \s+ (?P.+) | + (?Ppsource|pfile|pinfo|pinfo2) \s+ (?P.+) | + (?Psystem) \s+ (?P.+) | + (?Punalias) \s+ (?P.+) | + (?P[^\s]+) .* | + ) .* | + !(?P.+) | + (?![%!]) (?P.+) + ) + \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(f"{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(f"{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( + f"Error in loading extension: {ext}" + + f"\nCheck your config files in {ipy_utils.path.get_ipython_dir()}" + ) + shell.showtraceback() + + +def run_exec_lines(shell, exec_lines): + """ + Partial copy of run_exec_lines code from IPython.core.shellapp . + """ + try: + iter(exec_lines) + except TypeError: + pass + else: + try: + for line in exec_lines: + try: + shell.run_cell(line, store_history=False) + except: + shell.showtraceback() + except: + shell.showtraceback() + + +def embed(**kwargs): + """ + Copied from `IPython/terminal/embed.py`, but using our `InteractiveShellEmbed` instead. + """ + 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/src/ptpython/key_bindings.py b/src/ptpython/key_bindings.py new file mode 100644 index 00000000..48c5f5ae --- /dev/null +++ b/src/ptpython/key_bindings.py @@ -0,0 +1,339 @@ +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 typing_extensions import TypeAlias + + from .python_input import PythonInput + +__all__ = [ + "load_python_bindings", + "load_sidebar_bindings", + "load_confirm_exit_bindings", +] + +E: TypeAlias = KeyPressEvent + + +@Condition +def tab_should_insert_whitespace() -> 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/src/ptpython/layout.py b/src/ptpython/layout.py new file mode 100644 index 00000000..9768598e --- /dev/null +++ b/src/ptpython/layout.py @@ -0,0 +1,772 @@ +""" +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", f" {category.title:36}"), + ("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, f"{label:24}", select_item)) + tokens.append(("class:sidebar.status" + sel, " ", select_item)) + tokens.append(("class:sidebar.status" + sel, f"{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 = f"{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, + f"{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", + f"{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, f"\n {python_input.exit_message} ([y]/n) "), + ("[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/src/ptpython/lexer.py b/src/ptpython/lexer.py new file mode 100644 index 00000000..d925e95c --- /dev/null +++ b/src/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/src/ptpython/printer.py b/src/ptpython/printer.py new file mode 100644 index 00000000..a3578de7 --- /dev/null +++ b/src/ptpython/printer.py @@ -0,0 +1,439 @@ +from __future__ import annotations + +import traceback +from dataclasses import dataclass +from enum import Enum +from typing import Generator, Iterable + +from prompt_toolkit.formatted_text import ( + HTML, + AnyFormattedText, + FormattedText, + OneStyleAndTextTuple, + StyleAndTextTuples, + fragment_list_width, + merge_formatted_text, + to_formatted_text, +) +from prompt_toolkit.formatted_text.utils import split_lines +from prompt_toolkit.input import Input +from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent +from prompt_toolkit.output import Output +from prompt_toolkit.shortcuts import PromptSession, print_formatted_text +from prompt_toolkit.styles import BaseStyle, StyleTransformation +from prompt_toolkit.styles.pygments import pygments_token_to_classname +from prompt_toolkit.utils import get_cwidth +from pygments.lexers import PythonLexer, PythonTracebackLexer + +__all__ = ["OutputPrinter"] + +# Never reformat results larger than this: +MAX_REFORMAT_SIZE = 1_000_000 + + +@dataclass +class OutputPrinter: + """ + Result printer. + + Usage:: + + printer = OutputPrinter(...) + printer.display_result(...) + printer.display_exception(...) + """ + + output: Output + input: Input + style: BaseStyle + title: AnyFormattedText + style_transformation: StyleTransformation + + def display_result( + self, + result: object, + *, + out_prompt: AnyFormattedText, + reformat: bool, + highlight: bool, + paginate: bool, + ) -> None: + """ + Show __repr__ (or `__pt_repr__`) for an `eval` result and print to output. + + :param reformat: Reformat result using 'black' before printing if the + result is parsable as Python code. + :param highlight: Syntax highlight the result. + :param paginate: Show paginator when the result does not fit on the + screen. + """ + out_prompt = to_formatted_text(out_prompt) + out_prompt_width = fragment_list_width(out_prompt) + + result = self._insert_out_prompt_and_split_lines( + self._format_result_output( + result, + reformat=reformat, + highlight=highlight, + line_length=self.output.get_size().columns - out_prompt_width, + paginate=paginate, + ), + out_prompt=out_prompt, + ) + self._display_result(result, paginate=paginate) + + def display_exception( + self, e: BaseException, *, highlight: bool, paginate: bool + ) -> None: + """ + Render an exception. + """ + result = self._insert_out_prompt_and_split_lines( + self._format_exception_output(e, highlight=highlight), + out_prompt="", + ) + self._display_result(result, paginate=paginate) + + def display_style_and_text_tuples( + self, + result: Iterable[OneStyleAndTextTuple], + *, + paginate: bool, + ) -> None: + self._display_result( + self._insert_out_prompt_and_split_lines(result, out_prompt=""), + paginate=paginate, + ) + + def _display_result( + self, + lines: Iterable[StyleAndTextTuples], + *, + paginate: bool, + ) -> None: + if paginate: + self._print_paginated_formatted_text(lines) + else: + for line in lines: + self._print_formatted_text(line) + + self.output.flush() + + def _print_formatted_text(self, line: StyleAndTextTuples, end: str = "\n") -> None: + print_formatted_text( + FormattedText(line), + style=self.style, + style_transformation=self.style_transformation, + include_default_pygments_style=False, + output=self.output, + end=end, + ) + + def _format_result_output( + self, + result: object, + *, + reformat: bool, + highlight: bool, + line_length: int, + paginate: bool, + ) -> Generator[OneStyleAndTextTuple, None, None]: + """ + Format __repr__ for an `eval` result. + + Note: this can raise `KeyboardInterrupt` if either calling `__repr__`, + `__pt_repr__` or formatting the output with "Black" takes to long + and the user presses Control-C. + """ + # If __pt_repr__ is present, take this. This can return prompt_toolkit + # formatted text. + try: + if hasattr(result, "__pt_repr__"): + formatted_result_repr = to_formatted_text( + getattr(result, "__pt_repr__")() + ) + yield from formatted_result_repr + return + except (GeneratorExit, KeyboardInterrupt): + raise # Don't catch here. + except: + # For bad code, `__getattr__` can raise something that's not an + # `AttributeError`. This happens already when calling `hasattr()`. + pass + + # Call `__repr__` of given object first, to turn it in a string. + try: + result_repr = repr(result) + except KeyboardInterrupt: + raise # Don't catch here. + except BaseException as e: + # Calling repr failed. + self.display_exception(e, highlight=highlight, paginate=paginate) + return + + # Determine whether it's valid Python code. If not, + # reformatting/highlighting won't be applied. + if len(result_repr) < MAX_REFORMAT_SIZE: + try: + compile(result_repr, "", "eval") + except SyntaxError: + valid_python = False + else: + valid_python = True + else: + valid_python = False + + if valid_python and reformat: + # Inline import. Slightly speed up start-up time if black is + # not used. + try: + import black + + if not hasattr(black, "Mode"): + raise ImportError + except ImportError: + pass # no Black package in your installation + else: + result_repr = black.format_str( + result_repr, + mode=black.Mode(line_length=line_length), + ) + + if valid_python and highlight: + yield from _lex_python_result(result_repr) + else: + yield ("", result_repr) + + def _insert_out_prompt_and_split_lines( + self, result: Iterable[OneStyleAndTextTuple], out_prompt: AnyFormattedText + ) -> Iterable[StyleAndTextTuples]: + r""" + Split styled result in lines (based on the \n characters in the result) + an insert output prompt on whitespace in front of each line. (This does + not yet do the soft wrapping.) + + Yield lines as a result. + """ + out_prompt = to_formatted_text(out_prompt) + out_prompt_width = fragment_list_width(out_prompt) + prefix = ("", " " * out_prompt_width) + + for i, line in enumerate(split_lines(result)): + if i == 0: + line = [*out_prompt, *line] + else: + line = [prefix, *line] + yield line + + def _apply_soft_wrapping( + self, lines: Iterable[StyleAndTextTuples] + ) -> Iterable[StyleAndTextTuples]: + """ + Apply soft wrapping to the given lines. Wrap according to the terminal + width. Insert whitespace in front of each wrapped line to align it with + the output prompt. + """ + line_length = self.output.get_size().columns + + # Iterate over hard wrapped lines. + for lineno, line in enumerate(lines): + columns_in_buffer = 0 + current_line: list[OneStyleAndTextTuple] = [] + + for style, text, *_ in line: + for c in text: + width = get_cwidth(c) + + # (Soft) wrap line if it doesn't fit. + if columns_in_buffer + width > line_length: + yield current_line + columns_in_buffer = 0 + current_line = [] + + columns_in_buffer += width + current_line.append((style, c)) + + yield current_line + + def _print_paginated_formatted_text( + self, lines: Iterable[StyleAndTextTuples] + ) -> None: + """ + Print formatted text, using --MORE-- style pagination. + (Avoid filling up the terminal's scrollback buffer.) + """ + lines = self._apply_soft_wrapping(lines) + pager_prompt = create_pager_prompt( + self.style, self.title, output=self.output, input=self.input + ) + + abort = False + print_all = False + + # Max number of lines allowed in the buffer before painting. + size = self.output.get_size() + max_rows = size.rows - 1 + + # Page buffer. + page: StyleAndTextTuples = [] + + def show_pager() -> None: + nonlocal abort, max_rows, print_all + + # Run pager prompt in another thread. + # Same as for the input. This prevents issues with nested event + # loops. + pager_result = pager_prompt.prompt(in_thread=True) + + if pager_result == PagerResult.ABORT: + print("...") + abort = True + + elif pager_result == PagerResult.NEXT_LINE: + max_rows = 1 + + elif pager_result == PagerResult.NEXT_PAGE: + max_rows = size.rows - 1 + + elif pager_result == PagerResult.PRINT_ALL: + print_all = True + + # Loop over lines. Show --MORE-- prompt when page is filled. + rows = 0 + + for lineno, line in enumerate(lines): + page.extend(line) + page.append(("", "\n")) + rows += 1 + + if rows >= max_rows: + self._print_formatted_text(page, end="") + page = [] + rows = 0 + + if not print_all: + show_pager() + if abort: + return + + self._print_formatted_text(page) + + def _format_exception_output( + self, e: BaseException, highlight: bool + ) -> Generator[OneStyleAndTextTuple, None, None]: + if e.__cause__: + yield from self._format_exception_output(e.__cause__, highlight=highlight) + yield ( + "", + "\nThe above exception was the direct cause of the following exception:\n\n", + ) + elif e.__context__: + yield from self._format_exception_output(e.__context__, highlight=highlight) + yield ( + "", + "\nDuring handling of the above exception, another exception occurred:\n\n", + ) + + tblist = list(traceback.extract_tb(e.__traceback__)) + + for line_nr, tb_tuple in enumerate(tblist): + if tb_tuple[0] == "": + tblist = tblist[line_nr:] + break + + tb_list = traceback.format_list(tblist) + if tb_list: + tb_list.insert(0, "Traceback (most recent call last):\n") + tb_list.extend(traceback.format_exception_only(type(e), e)) + + tb_str = "".join(tb_list) + + # Format exception and write to output. + # (We use the default style. Most other styles result + # in unreadable colors for the traceback.) + if highlight: + for index, tokentype, text in PythonTracebackLexer().get_tokens_unprocessed( + tb_str + ): + yield ("class:" + pygments_token_to_classname(tokentype), text) + else: + yield ("", tb_str) + + +class PagerResult(Enum): + ABORT = "ABORT" + NEXT_LINE = "NEXT_LINE" + NEXT_PAGE = "NEXT_PAGE" + PRINT_ALL = "PRINT_ALL" + + +def create_pager_prompt( + style: BaseStyle, + title: AnyFormattedText = "", + input: Input | None = None, + output: Output | None = None, +) -> PromptSession[PagerResult]: + """ + Create a "--MORE--" prompt for paginated output. + """ + bindings = KeyBindings() + + @bindings.add("enter") + @bindings.add("down") + def next_line(event: KeyPressEvent) -> None: + event.app.exit(result=PagerResult.NEXT_LINE) + + @bindings.add("space") + def next_page(event: KeyPressEvent) -> None: + event.app.exit(result=PagerResult.NEXT_PAGE) + + @bindings.add("a") + def print_all(event: KeyPressEvent) -> None: + event.app.exit(result=PagerResult.PRINT_ALL) + + @bindings.add("q") + @bindings.add("c-c") + @bindings.add("c-d") + @bindings.add("escape", eager=True) + def no(event: KeyPressEvent) -> None: + event.app.exit(result=PagerResult.ABORT) + + @bindings.add("") + def _(event: KeyPressEvent) -> None: + "Disallow inserting other text." + pass + + session: PromptSession[PagerResult] = PromptSession( + merge_formatted_text( + [ + title, + HTML( + "" + " -- MORE -- " + "[Enter] Scroll " + "[Space] Next page " + "[a] Print all " + "[q] Quit " + ": " + ), + ] + ), + key_bindings=bindings, + erase_when_done=True, + style=style, + input=input, + output=output, + ) + return session + + +def _lex_python_result(result: str) -> Generator[tuple[str, str], None, None]: + "Return token list for Python string." + lexer = PythonLexer() + # Use `get_tokens_unprocessed`, so that we get exactly the same string, + # without line endings appended. `print_formatted_text` already appends a + # line ending, and otherwise we'll have two line endings. + tokens = lexer.get_tokens_unprocessed(result) + + for index, tokentype, text in tokens: + yield ("class:" + pygments_token_to_classname(tokentype), text) diff --git a/src/ptpython/prompt_style.py b/src/ptpython/prompt_style.py new file mode 100644 index 00000000..465c3dbe --- /dev/null +++ b/src/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", f"{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", f"{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/entry_points/__init__.py b/src/ptpython/py.typed similarity index 100% rename from ptpython/entry_points/__init__.py rename to src/ptpython/py.typed diff --git a/src/ptpython/python_input.py b/src/ptpython/python_input.py new file mode 100644 index 00000000..b1773643 --- /dev/null +++ b/src/ptpython/python_input.py @@ -0,0 +1,1120 @@ +""" +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(), + } + + #: 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 get_input_prompt(self) -> AnyFormattedText: + return self.all_prompt_styles[self.prompt_style].in_prompt() + + def get_output_prompt(self) -> AnyFormattedText: + return self.all_prompt_styles[self.prompt_style].out_prompt() + + def _accept_handler(self, buff: Buffer) -> bool: + app = get_app() + app.exit(result=buff.text) + 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: f"{self.min_brightness:.2f}", + get_values=lambda: { + f"{value:.2f}": partial(self._set_min_brightness, value) + for value in brightness_values + }, + ), + Option( + title="Max brightness", + description="Maximum brightness for the color scheme (default=1.0).", + get_current_value=lambda: f"{self.max_brightness:.2f}", + get_values=lambda: { + f"{value:.2f}": 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.signatures = [] + self.default_buffer.document = Document() diff --git a/src/ptpython/repl.py b/src/ptpython/repl.py new file mode 100644 index 00000000..469ed694 --- /dev/null +++ b/src/ptpython/repl.py @@ -0,0 +1,618 @@ +""" +Utility for creating a Python repl. + +:: + + from ptpython.repl import embed + embed(globals(), locals(), vi_mode=False) + +""" + +from __future__ import annotations + +import asyncio +import builtins +import os +import signal +import sys +import traceback +import types +import warnings +from dis import COMPILER_FLAG_NAMES +from pathlib import Path +from typing import ( + Any, + Callable, + ContextManager, + Coroutine, + Iterable, + Literal, + NoReturn, + Sequence, + overload, +) + +from prompt_toolkit.formatted_text import OneStyleAndTextTuple +from prompt_toolkit.patch_stdout import patch_stdout as patch_stdout_context +from prompt_toolkit.shortcuts import ( + clear_title, + set_title, +) +from prompt_toolkit.utils import DummyContext +from pygments.lexers import PythonTracebackLexer # noqa: F401 + +from .printer import OutputPrinter +from .python_input import PythonInput + +PyCF_ALLOW_TOP_LEVEL_AWAIT: int +try: + from ast import PyCF_ALLOW_TOP_LEVEL_AWAIT # type: ignore +except ImportError: + PyCF_ALLOW_TOP_LEVEL_AWAIT = 0 + + +__all__ = [ + "PythonRepl", + "enable_deprecation_warnings", + "run_config", + "embed", + "exit", + "ReplExit", +] + + +def _get_coroutine_flag() -> int | None: + for k, v in COMPILER_FLAG_NAMES.items(): + if v == "COROUTINE": + return k + + # Flag not found. + return None + + +COROUTINE_FLAG: int | None = _get_coroutine_flag() + + +def _has_coroutine_flag(code: types.CodeType) -> bool: + if COROUTINE_FLAG is None: + # Not supported on this Python version. + return False + + return bool(code.co_flags & COROUTINE_FLAG) + + +class PythonRepl(PythonInput): + def __init__(self, *a, **kw) -> None: + self._startup_paths: Sequence[str | Path] | None = kw.pop("startup_paths", None) + super().__init__(*a, **kw) + self._load_start_paths() + + def _load_start_paths(self) -> None: + "Start the Read-Eval-Print Loop." + if self._startup_paths: + for path in self._startup_paths: + if os.path.exists(path): + with open(path, "rb") as f: + code = compile(f.read(), path, "exec") + exec(code, self.get_globals(), self.get_locals()) + else: + output = self.app.output + output.write(f"WARNING | File not found: {path}\n\n") + + def run_and_show_expression(self, expression: str) -> None: + try: + # Eval. + try: + result = self.eval(expression) + except KeyboardInterrupt: + # KeyboardInterrupt doesn't inherit from Exception. + raise + except SystemExit: + raise + except ReplExit: + raise + except BaseException as e: + self._handle_exception(e) + else: + if isinstance(result, exit): + # When `exit` is evaluated without parentheses. + # Automatically trigger the `ReplExit` exception. + raise ReplExit + + # Print. + if result is not None: + self._show_result(result) + if self.insert_blank_line_after_output: + self.app.output.write("\n") + + # Loop. + self.current_statement_index += 1 + self.signatures = [] + + except KeyboardInterrupt as e: + # Handle all possible `KeyboardInterrupt` errors. This can + # happen during the `eval`, but also during the + # `show_result` if something takes too long. + # (Try/catch is around the whole block, because we want to + # prevent that a Control-C keypress terminates the REPL in + # any case.) + self._handle_keyboard_interrupt(e) + + def _get_output_printer(self) -> OutputPrinter: + return OutputPrinter( + output=self.app.output, + input=self.app.input, + style=self._current_style, + style_transformation=self.style_transformation, + title=self.title, + ) + + def _show_result(self, result: object) -> None: + self._get_output_printer().display_result( + result=result, + out_prompt=self.get_output_prompt(), + reformat=self.enable_output_formatting, + highlight=self.enable_syntax_highlighting, + paginate=self.enable_pager, + ) + + def run(self) -> None: + """ + Run the REPL loop. + """ + if self.terminal_title: + set_title(self.terminal_title) + + self._add_to_namespace() + + try: + while True: + # Pull text from the user. + try: + text = self.read() + except EOFError: + return + except BaseException: + # Something went wrong while reading input. + # (E.g., a bug in the completer that propagates. Don't + # crash the REPL.) + traceback.print_exc() + continue + + # Run it; display the result (or errors if applicable). + try: + self.run_and_show_expression(text) + except ReplExit: + return + finally: + if self.terminal_title: + clear_title() + self._remove_from_namespace() + + async def run_and_show_expression_async(self, text: str) -> Any: + loop = asyncio.get_running_loop() + system_exit: SystemExit | None = None + + try: + try: + # Create `eval` task. Ensure that control-c will cancel this + # task. + async def eval() -> Any: + nonlocal system_exit + try: + return await self.eval_async(text) + except SystemExit as e: + # Don't propagate SystemExit in `create_task()`. That + # will kill the event loop. We want to handle it + # gracefully. + system_exit = e + + task = asyncio.create_task(eval()) + loop.add_signal_handler(signal.SIGINT, lambda *_: task.cancel()) + result = await task + + if system_exit is not None: + raise system_exit + except KeyboardInterrupt: + # KeyboardInterrupt doesn't inherit from Exception. + raise + except SystemExit: + raise + except BaseException as e: + self._handle_exception(e) + else: + # Print. + if result is not None: + await loop.run_in_executor(None, lambda: self._show_result(result)) + + # Loop. + self.current_statement_index += 1 + self.signatures = [] + # Return the result for future consumers. + return result + finally: + loop.remove_signal_handler(signal.SIGINT) + + except KeyboardInterrupt as e: + # Handle all possible `KeyboardInterrupt` errors. This can + # happen during the `eval`, but also during the + # `show_result` if something takes too long. + # (Try/catch is around the whole block, because we want to + # prevent that a Control-C keypress terminates the REPL in + # any case.) + self._handle_keyboard_interrupt(e) + + async def run_async(self) -> None: + """ + Run the REPL loop, but run the blocking parts in an executor, so that + we don't block the event loop. Both the input and output (which can + display a pager) will run in a separate thread with their own event + loop, this way ptpython's own event loop won't interfere with the + asyncio event loop from where this is called. + + The "eval" however happens in the current thread, which is important. + (Both for control-C to work, as well as for the code to see the right + thread in which it was embedded). + """ + loop = asyncio.get_running_loop() + + if self.terminal_title: + set_title(self.terminal_title) + + self._add_to_namespace() + + try: + while True: + try: + # Read. + try: + text = await loop.run_in_executor(None, self.read) + except EOFError: + return + except BaseException: + # Something went wrong while reading input. + # (E.g., a bug in the completer that propagates. Don't + # crash the REPL.) + traceback.print_exc() + continue + + # Eval. + await self.run_and_show_expression_async(text) + + except KeyboardInterrupt as e: + # XXX: This does not yet work properly. In some situations, + # `KeyboardInterrupt` exceptions can end up in the event + # loop selector. + self._handle_keyboard_interrupt(e) + except SystemExit: + return + finally: + if self.terminal_title: + clear_title() + self._remove_from_namespace() + + def eval(self, line: str) -> object: + """ + Evaluate the line and print the result. + """ + # WORKAROUND: Due to a bug in Jedi, the current directory is removed + # from sys.path. See: https://github.com/davidhalter/jedi/issues/1148 + if "" not in sys.path: + sys.path.insert(0, "") + + if line.lstrip().startswith("!"): + # Run as shell command + os.system(line[1:]) + else: + # Try eval first + try: + code = self._compile_with_flags(line, "eval") + except SyntaxError: + pass + else: + # No syntax errors for eval. Do eval. + result = eval(code, self.get_globals(), self.get_locals()) + + if _has_coroutine_flag(code): + result = asyncio.get_running_loop().run_until_complete(result) + + self._store_eval_result(result) + return result + + # If not a valid `eval` expression, run using `exec` instead. + # Note that we shouldn't run this in the `except SyntaxError` block + # above, then `sys.exc_info()` would not report the right error. + # See issue: https://github.com/prompt-toolkit/ptpython/issues/435 + code = self._compile_with_flags(line, "exec") + result = eval(code, self.get_globals(), self.get_locals()) + + if _has_coroutine_flag(code): + result = asyncio.get_running_loop().run_until_complete(result) + + return None + + async def eval_async(self, line: str) -> object: + """ + Evaluate the line and print the result. + """ + # WORKAROUND: Due to a bug in Jedi, the current directory is removed + # from sys.path. See: https://github.com/davidhalter/jedi/issues/1148 + if "" not in sys.path: + sys.path.insert(0, "") + + if line.lstrip().startswith("!"): + # Run as shell command + os.system(line[1:]) + else: + # Try eval first + try: + code = self._compile_with_flags(line, "eval") + except SyntaxError: + pass + else: + # No syntax errors for eval. Do eval. + result = eval(code, self.get_globals(), self.get_locals()) + + if _has_coroutine_flag(code): + result = await result + + self._store_eval_result(result) + return result + + # If not a valid `eval` expression, compile as `exec` expression + # but still run with eval to get an awaitable in case of a + # awaitable expression. + code = self._compile_with_flags(line, "exec") + result = eval(code, self.get_globals(), self.get_locals()) + + if _has_coroutine_flag(code): + result = await result + + return None + + def _store_eval_result(self, result: object) -> None: + locals: dict[str, Any] = self.get_locals() + locals["_"] = locals[f"_{self.current_statement_index}"] = result + + def get_compiler_flags(self) -> int: + return super().get_compiler_flags() | PyCF_ALLOW_TOP_LEVEL_AWAIT + + def _compile_with_flags(self, code: str, mode: str) -> Any: + "Compile code with the right compiler flags." + return compile( + code, + "", + mode, + flags=self.get_compiler_flags(), + dont_inherit=True, + ) + + def _handle_exception(self, e: BaseException) -> None: + # Required for pdb.post_mortem() to work. + t, v, tb = sys.exc_info() + sys.last_type, sys.last_value, sys.last_traceback = t, v, tb + + self._get_output_printer().display_exception( + e, + highlight=self.enable_syntax_highlighting, + paginate=self.enable_pager, + ) + + def _handle_keyboard_interrupt(self, e: KeyboardInterrupt) -> None: + output = self.app.output + + output.write("\rKeyboardInterrupt\n\n") + output.flush() + + def _add_to_namespace(self) -> None: + """ + Add ptpython built-ins to global namespace. + """ + globals = self.get_globals() + + # Add a 'get_ptpython', similar to 'get_ipython' + def get_ptpython() -> PythonInput: + return self + + globals["get_ptpython"] = get_ptpython + globals["exit"] = exit() + + def _remove_from_namespace(self) -> None: + """ + Remove added symbols from the globals. + """ + globals = self.get_globals() + del globals["get_ptpython"] + + def print_paginated_formatted_text( + self, + formatted_text: Iterable[OneStyleAndTextTuple], + end: str = "\n", + ) -> None: + # Warning: This is mainly here backwards-compatibility. Some projects + # call `print_paginated_formatted_text` on the Repl object. + self._get_output_printer().display_style_and_text_tuples( + formatted_text, paginate=True + ) + + +def enable_deprecation_warnings() -> None: + """ + Show deprecation warnings, when they are triggered directly by actions in + the REPL. This is recommended to call, before calling `embed`. + + e.g. This will show an error message when the user imports the 'sha' + library on Python 2.7. + """ + warnings.filterwarnings("default", category=DeprecationWarning, module="__main__") + + +DEFAULT_CONFIG_FILE = "~/.config/ptpython/config.py" + + +def run_config(repl: PythonInput, config_file: str | None = None) -> None: + """ + Execute REPL config file. + + :param repl: `PythonInput` instance. + :param config_file: Path of the configuration file. + """ + explicit_config_file = config_file is not None + + # Expand tildes. + config_file = os.path.expanduser( + config_file if config_file is not None else DEFAULT_CONFIG_FILE + ) + + def enter_to_continue() -> None: + input("\nPress ENTER to continue...") + + # Check whether this file exists. + if not os.path.exists(config_file): + if explicit_config_file: + print(f"Impossible to read {config_file}") + enter_to_continue() + return + + # Run the config file in an empty namespace. + try: + namespace: dict[str, Any] = {} + + with open(config_file, "rb") as f: + code = compile(f.read(), config_file, "exec") + exec(code, namespace, namespace) + + # Now we should have a 'configure' method in this namespace. We call this + # method with the repl as an argument. + if "configure" in namespace: + namespace["configure"](repl) + + except Exception: + traceback.print_exc() + enter_to_continue() + + +class exit: + """ + Exit the ptpython REPL. + """ + + # This custom exit function ensures that the `embed` function returns from + # where we are embedded, and Python doesn't close `sys.stdin` like + # the default `exit` from `_sitebuiltins.Quitter` does. + + def __call__(self) -> NoReturn: + raise ReplExit + + def __repr__(self) -> str: + # (Same message as the built-in Python REPL.) + return "Use exit() or Ctrl-D (i.e. EOF) to exit" + + +class ReplExit(Exception): + """ + Exception raised by ptpython's exit function. + """ + + +@overload +def embed( + globals: dict[str, Any] | None = ..., + locals: dict[str, Any] | None = ..., + configure: Callable[[PythonRepl], None] | None = ..., + vi_mode: bool = ..., + history_filename: str | None = ..., + title: str | None = ..., + startup_paths: Sequence[str | Path] | None = ..., + patch_stdout: bool = ..., + return_asyncio_coroutine: Literal[False] = ..., +) -> None: ... + + +@overload +def embed( + globals: dict[str, Any] | None = ..., + locals: dict[str, Any] | None = ..., + configure: Callable[[PythonRepl], None] | None = ..., + vi_mode: bool = ..., + history_filename: str | None = ..., + title: str | None = ..., + startup_paths: Sequence[str | Path] | None = ..., + patch_stdout: bool = ..., + return_asyncio_coroutine: Literal[True] = ..., +) -> Coroutine[Any, Any, None]: ... + + +def embed( + globals: dict[str, Any] | None = None, + locals: dict[str, Any] | None = None, + configure: Callable[[PythonRepl], None] | None = None, + vi_mode: bool = False, + history_filename: str | None = None, + title: str | None = None, + startup_paths: Sequence[str | Path] | None = None, + patch_stdout: bool = False, + return_asyncio_coroutine: bool = False, +) -> None | Coroutine[Any, Any, None]: + """ + Call this to embed Python shell at the current point in your program. + It's similar to `IPython.embed` and `bpython.embed`. :: + + from prompt_toolkit.contrib.repl import embed + embed(globals(), locals()) + + :param vi_mode: Boolean. Use Vi instead of Emacs key bindings. + :param configure: Callable that will be called with the `PythonRepl` as a first + argument, to trigger configuration. + :param title: Title to be displayed in the terminal titlebar. (None or string.) + :param patch_stdout: When true, patch `sys.stdout` so that background + threads that are printing will print nicely above the prompt. + """ + # Default globals/locals + if globals is None: + globals = { + "__name__": "__main__", + "__package__": None, + "__doc__": None, + "__builtins__": builtins, + } + + locals = locals or globals + + def get_globals() -> dict[str, Any]: + return globals + + def get_locals() -> dict[str, Any]: + return locals + + # Create REPL. + repl = PythonRepl( + get_globals=get_globals, + get_locals=get_locals, + vi_mode=vi_mode, + history_filename=history_filename, + startup_paths=startup_paths, + ) + + if title: + repl.terminal_title = title + + if configure: + configure(repl) + + # Start repl. + patch_context: ContextManager[None] = ( + patch_stdout_context() if patch_stdout else DummyContext() + ) + + if return_asyncio_coroutine: + + async def coroutine() -> None: + with patch_context: + await repl.run_async() + + return coroutine() # type: ignore + else: + with patch_context: + repl.run() + return None diff --git a/src/ptpython/signatures.py b/src/ptpython/signatures.py new file mode 100644 index 00000000..b3e5c914 --- /dev/null +++ b/src/ptpython/signatures.py @@ -0,0 +1,271 @@ +""" +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/src/ptpython/style.py b/src/ptpython/style.py new file mode 100644 index 00000000..c5a04e58 --- /dev/null +++ b/src/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/src/ptpython/utils.py b/src/ptpython/utils.py new file mode 100644 index 00000000..92cfc2a1 --- /dev/null +++ b/src/ptpython/utils.py @@ -0,0 +1,213 @@ +""" +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/src/ptpython/validator.py similarity index 52% rename from ptpython/validator.py rename to src/ptpython/validator.py index 80cc3fb1..cf2ee542 100644 --- a/ptpython/validator.py +++ b/src/ptpython/validator.py @@ -1,10 +1,14 @@ -from __future__ import unicode_literals +from __future__ import annotations -from prompt_toolkit.validation import Validator, ValidationError +from typing import Callable + +from prompt_toolkit.document import Document +from prompt_toolkit.validation import ValidationError, Validator + +from .utils import unindent_code + +__all__ = ["PythonValidator"] -__all__ = ( - 'PythonValidator', -) class PythonValidator(Validator): """ @@ -13,16 +17,24 @@ class PythonValidator(Validator): :param get_compiler_flags: Callable that returns the currently active compiler flags. """ - def __init__(self, get_compiler_flags=None): + + def __init__(self, get_compiler_flags: Callable[[], int] | None = None) -> None: self.get_compiler_flags = get_compiler_flags - def validate(self, document): + def validate(self, document: Document) -> None: """ Check input for Python syntax errors. """ + text = unindent_code(document.text) + # When the input starts with Ctrl-Z, always accept. This means EOF in a # Python REPL. - if document.text.startswith('\x1a'): + if text.startswith("\x1a"): + return + + # When the input starts with an exclamation mark. Accept as shell + # command. + if text.lstrip().startswith("!"): return try: @@ -31,17 +43,20 @@ def validate(self, document): else: flags = 0 - compile(document.text, '', 'exec', flags=flags, dont_inherit=True) + compile(text, "", "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.) - index = document.translate_row_col_to_index(e.lineno - 1, (e.offset or 1) - 1) - raise ValidationError(index, 'Syntax Error') + # 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) + raise ValidationError(0, f"Syntax Error: {e}") diff --git a/tests/run_tests.py b/tests/run_tests.py deleted file mode 100755 index a23fddec..00000000 --- a/tests/run_tests.py +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env python -from __future__ import unicode_literals - -import unittest - - -# 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. - -import ptpython.completer -import ptpython.filters -#import ptpython.ipython -import ptpython.eventloop -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 - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/test_dummy.py b/tests/test_dummy.py new file mode 100755 index 00000000..922c6a39 --- /dev/null +++ b/tests/test_dummy.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python +from __future__ import annotations + +import ptpython.completer +import ptpython.eventloop +import ptpython.filters +import ptpython.history_browser +import ptpython.key_bindings +import ptpython.layout +import ptpython.python_input +import ptpython.repl +import ptpython.style +import ptpython.utils +import ptpython.validator + +# For now there are no tests here. +# However this is sufficient to do at least a syntax check. + + +def test_dummy() -> None: + assert ptpython.completer + assert ptpython.eventloop + assert ptpython.filters + assert ptpython.history_browser + assert ptpython.key_bindings + assert ptpython.layout + assert ptpython.python_input + assert ptpython.repl + assert ptpython.style + assert ptpython.utils + assert ptpython.validator